maybeai-sheet-cli 0.7.0__tar.gz → 0.8.0__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 (54) hide show
  1. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/PKG-INFO +7 -1
  2. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/README.md +6 -0
  3. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/pyproject.toml +1 -1
  4. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/__init__.py +1 -1
  5. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/excel_worksheet.py +44 -0
  6. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/range.py +42 -0
  7. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/workbook.py +24 -0
  8. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/build_fixtures.py +213 -0
  9. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
  10. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
  11. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
  12. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/rows.json +12 -0
  13. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
  14. maybeai_sheet_cli-0.8.0/tests/fixtures/benchmarks/simple-values.json +17 -0
  15. maybeai_sheet_cli-0.8.0/tests/test_benchmark_runner.py +154 -0
  16. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_cli.py +35 -0
  17. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_phase2_cli.py +28 -0
  18. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/.gitignore +0 -0
  19. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/cli.py +0 -0
  20. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/client.py +0 -0
  21. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/__init__.py +0 -0
  22. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/_phase2.py +0 -0
  23. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/cell.py +0 -0
  24. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/column.py +0 -0
  25. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/db_table.py +0 -0
  26. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/excel_table.py +0 -0
  27. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/file.py +0 -0
  28. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/formula.py +0 -0
  29. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/history.py +0 -0
  30. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/raw.py +0 -0
  31. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/row.py +0 -0
  32. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/share.py +0 -0
  33. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/sheet.py +0 -0
  34. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/style.py +0 -0
  35. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/table.py +0 -0
  36. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/commands/worksheet.py +0 -0
  37. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/config.py +0 -0
  38. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/endpoints.py +0 -0
  39. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/errors.py +0 -0
  40. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/formatters.py +0 -0
  41. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/models/__init__.py +0 -0
  42. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/models/share.py +0 -0
  43. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/models/sheet.py +0 -0
  44. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/models/workbook.py +0 -0
  45. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/options.py +0 -0
  46. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/protocol.py +0 -0
  47. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/resolver.py +0 -0
  48. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/table_resolver.py +0 -0
  49. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/src/maybeai_sheet/update.py +0 -0
  50. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_endpoints.py +0 -0
  51. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_formatters.py +0 -0
  52. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_resolver.py +0 -0
  53. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_two_engine_cli.py +0 -0
  54. {maybeai_sheet_cli-0.7.0 → maybeai_sheet_cli-0.8.0}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maybeai-sheet-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
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
@@ -167,6 +167,8 @@ Search, style, inspect history, and export:
167
167
  ```bash
168
168
  mbs excel-worksheet range search --doc-id abc123 --worksheet-name Sheet1 --query revenue --max-results 20
169
169
  mbs excel-worksheet style freeze-panes --doc-id abc123 --worksheet-name Sheet1 --cell B2
170
+ mbs workbook calculate --doc-id abc123
171
+ mbs excel-worksheet calculate --doc-id abc123 --worksheet-name Sheet1
170
172
  mbs excel-worksheet range calculate --doc-id abc123 --worksheet-name Sheet1 --cell E2 --formula "=SUM(B2:D2)"
171
173
  mbs version list --doc-id abc123 --limit 10
172
174
  mbs workbook export --doc-id abc123 --out workbook.xlsx
@@ -216,6 +218,7 @@ mbs
216
218
  │ ├── list-user-workbooks API List user workbooks with bounded output.
217
219
  | list-worksheets API List worksheets, including engine.
218
220
  │ ├── search NOW Search user workbooks with bounded output.
221
+ │ ├── calculate NOW Recalculate formulas across the workbook.
219
222
  │ ├── export API Export workbook bytes.
220
223
  │ └── copy API Copy a workbook.
221
224
  │ ├── upload API Upload a file through workbook import flow.
@@ -226,6 +229,7 @@ mbs
226
229
 
227
230
  ├── excel_worksheet
228
231
  │ ├── read API Read the full range of the worksheet.
232
+ │ ├── calculate NOW Recalculate formulas in one worksheet.
229
233
  │ ├── list-table API Detect content-backed tables in a worksheet.
230
234
  │ ├── create API Create a worksheet, optionally with starter values.
231
235
  │ ├── update API Update worksheet metadata.
@@ -394,6 +398,8 @@ mbs excel-worksheet column append --doc-id abc123 --worksheet-name Actuals --val
394
398
  mbs excel-worksheet range lineage --doc-id abc123 --worksheet-name Model --cell E2 --format tree # API
395
399
 
396
400
  # formula
401
+ mbs workbook calculate --doc-id abc123 # API
402
+ mbs excel-worksheet calculate --doc-id abc123 --worksheet-name Model # API
397
403
  mbs excel-worksheet range calculate --doc-id abc123 --worksheet-name Model --cell E2 --formula "=SUM(B2:D2)" # API
398
404
  mbs excel-worksheet range set-formula --doc-id abc123 --worksheet-name Model --cell E2 --formula "=SUM(B2:D2)" # API
399
405
 
@@ -143,6 +143,8 @@ Search, style, inspect history, and export:
143
143
  ```bash
144
144
  mbs excel-worksheet range search --doc-id abc123 --worksheet-name Sheet1 --query revenue --max-results 20
145
145
  mbs excel-worksheet style freeze-panes --doc-id abc123 --worksheet-name Sheet1 --cell B2
146
+ mbs workbook calculate --doc-id abc123
147
+ mbs excel-worksheet calculate --doc-id abc123 --worksheet-name Sheet1
146
148
  mbs excel-worksheet range calculate --doc-id abc123 --worksheet-name Sheet1 --cell E2 --formula "=SUM(B2:D2)"
147
149
  mbs version list --doc-id abc123 --limit 10
148
150
  mbs workbook export --doc-id abc123 --out workbook.xlsx
@@ -192,6 +194,7 @@ mbs
192
194
  │ ├── list-user-workbooks API List user workbooks with bounded output.
193
195
  | list-worksheets API List worksheets, including engine.
194
196
  │ ├── search NOW Search user workbooks with bounded output.
197
+ │ ├── calculate NOW Recalculate formulas across the workbook.
195
198
  │ ├── export API Export workbook bytes.
196
199
  │ └── copy API Copy a workbook.
197
200
  │ ├── upload API Upload a file through workbook import flow.
@@ -202,6 +205,7 @@ mbs
202
205
 
203
206
  ├── excel_worksheet
204
207
  │ ├── read API Read the full range of the worksheet.
208
+ │ ├── calculate NOW Recalculate formulas in one worksheet.
205
209
  │ ├── list-table API Detect content-backed tables in a worksheet.
206
210
  │ ├── create API Create a worksheet, optionally with starter values.
207
211
  │ ├── update API Update worksheet metadata.
@@ -370,6 +374,8 @@ mbs excel-worksheet column append --doc-id abc123 --worksheet-name Actuals --val
370
374
  mbs excel-worksheet range lineage --doc-id abc123 --worksheet-name Model --cell E2 --format tree # API
371
375
 
372
376
  # formula
377
+ mbs workbook calculate --doc-id abc123 # API
378
+ mbs excel-worksheet calculate --doc-id abc123 --worksheet-name Model # API
373
379
  mbs excel-worksheet range calculate --doc-id abc123 --worksheet-name Model --cell E2 --formula "=SUM(B2:D2)" # API
374
380
  mbs excel-worksheet range set-formula --doc-id abc123 --worksheet-name Model --cell E2 --formula "=SUM(B2:D2)" # API
375
381
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "maybeai-sheet-cli"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "CLI for common MaybeAI spreadsheet operations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.7.0"
5
+ __version__ = "0.8.0"
@@ -16,6 +16,7 @@ from ..table_resolver import filter_content_backed_excel_tables
16
16
  from .column import app as column_app
17
17
  from .range import app as range_app
18
18
  from .row import app as row_app
19
+ from .sheet import recalculate as sheet_recalculate
19
20
  from .style import app as style_app
20
21
  from .worksheet import copy as worksheet_copy
21
22
  from .worksheet import create as worksheet_create
@@ -232,6 +233,49 @@ def move(
232
233
  )
233
234
 
234
235
 
236
+ @app.command("calculate")
237
+ def calculate(
238
+ ctx: typer.Context,
239
+ doc_id: Optional[str] = typer.Option(None, "--doc-id", help="MaybeAI document ID."),
240
+ url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
241
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
242
+ gid: Optional[int] = typer.Option(None, "--gid", help="Worksheet gid."),
243
+ worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Worksheet name."),
244
+ output: Optional[str] = output_option(),
245
+ verbose: Optional[bool] = verbose_option(),
246
+ timeout: Optional[float] = timeout_option(),
247
+ ) -> None:
248
+ """Recalculate formulas in one worksheet."""
249
+ try:
250
+ if gid is not None and worksheet_name is None:
251
+ state = resolve_command_state(
252
+ ctx,
253
+ doc_id=doc_id,
254
+ url=url,
255
+ uri=uri,
256
+ gid=gid,
257
+ output=output,
258
+ verbose=verbose,
259
+ timeout=timeout,
260
+ )
261
+ target = resolve_target(state, force_gid_uri=True)
262
+ worksheet_name = target.worksheet_name
263
+ uri = target.uri
264
+ doc_id = target.document_id
265
+ sheet_recalculate(
266
+ ctx,
267
+ doc_id=doc_id,
268
+ url=url,
269
+ uri=uri,
270
+ worksheet_name=worksheet_name,
271
+ output=output,
272
+ verbose=verbose,
273
+ timeout=timeout,
274
+ )
275
+ except Exception as error: # pragma: no cover - CLI boundary
276
+ handle_cli_error(error)
277
+
278
+
235
279
  app.add_typer(range_app, name="range")
236
280
  app.add_typer(row_app, name="row")
237
281
  app.add_typer(column_app, name="column")
@@ -132,6 +132,7 @@ def search(
132
132
  verbose: Optional[bool] = verbose_option(),
133
133
  timeout: Optional[float] = timeout_option(),
134
134
  ) -> None:
135
+ """Search worksheet cells for text."""
135
136
  try:
136
137
  state = common_options(
137
138
  ctx,
@@ -161,3 +162,44 @@ def search(
161
162
  )
162
163
  except Exception as error: # pragma: no cover - CLI boundary
163
164
  handle_cli_error(error)
165
+
166
+
167
+ @app.command("calculate")
168
+ def calculate(
169
+ ctx: typer.Context,
170
+ doc_id: Optional[str] = typer.Option(None, "--doc-id", help="MaybeAI document ID."),
171
+ url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
172
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
173
+ worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Worksheet name."),
174
+ cell: str = typer.Option(..., "--cell", help="A1 cell address to calculate."),
175
+ formula: str = typer.Option(..., "--formula", help="Formula text to calculate."),
176
+ output: Optional[str] = output_option(),
177
+ verbose: Optional[bool] = verbose_option(),
178
+ timeout: Optional[float] = timeout_option(),
179
+ ) -> None:
180
+ """Calculate one formula expression for a worksheet cell."""
181
+ try:
182
+ state = common_options(
183
+ ctx,
184
+ doc_id=doc_id,
185
+ url=url,
186
+ uri=uri,
187
+ gid=None,
188
+ worksheet_name=worksheet_name,
189
+ output=output,
190
+ verbose=verbose,
191
+ timeout=timeout,
192
+ )
193
+ target = command_target(state)
194
+ require_worksheet(target)
195
+ payload = base_payload(target) | {"cellAddress": cell, "formula": formula}
196
+ run_post_command(
197
+ state=state,
198
+ endpoint="/api/v1/excel/calc-formula",
199
+ payload=payload,
200
+ target=target,
201
+ client_class=MaybeAIClient,
202
+ command_id="formula.calculate",
203
+ )
204
+ except Exception as error: # pragma: no cover - CLI boundary
205
+ handle_cli_error(error)
@@ -14,6 +14,7 @@ from ..models.workbook import CreateWorkbookRequest
14
14
  from ..options import output_option, resolve_command_state, timeout_option, verbose_option
15
15
  from ..resolver import ResolvedTarget, parse_document_target, resolve_target
16
16
  from ._phase2 import bounded_limit
17
+ from .sheet import recalculate as sheet_recalculate
17
18
 
18
19
  app = typer.Typer(help="Workbook-level operations.", no_args_is_help=True)
19
20
 
@@ -252,6 +253,29 @@ def import_workbook(
252
253
  )
253
254
 
254
255
 
256
+ @app.command("calculate")
257
+ def calculate(
258
+ ctx: typer.Context,
259
+ doc_id: Optional[str] = typer.Option(None, "--doc-id", help="MaybeAI document ID."),
260
+ url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
261
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
262
+ output: Optional[str] = output_option(),
263
+ verbose: Optional[bool] = verbose_option(),
264
+ timeout: Optional[float] = timeout_option(),
265
+ ) -> None:
266
+ """Recalculate formulas across the workbook."""
267
+ sheet_recalculate(
268
+ ctx,
269
+ doc_id=doc_id,
270
+ url=url,
271
+ uri=uri,
272
+ worksheet_name=None,
273
+ output=output,
274
+ verbose=verbose,
275
+ timeout=timeout,
276
+ )
277
+
278
+
255
279
  @app.command("list")
256
280
  def list_workbooks(
257
281
  ctx: typer.Context,
@@ -0,0 +1,213 @@
1
+ """Build deterministic XLSX and JSON fixtures for the mbs benchmark suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from zipfile import ZIP_DEFLATED, ZipFile
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+
13
+
14
+ def cell_ref(row: int, col: int) -> str:
15
+ letters = ""
16
+ while col:
17
+ col, rem = divmod(col - 1, 26)
18
+ letters = chr(65 + rem) + letters
19
+ return f"{letters}{row}"
20
+
21
+
22
+ def cell_xml(row: int, col: int, value: object) -> str:
23
+ ref = cell_ref(row, col)
24
+ if value is None or value == "":
25
+ return f'<c r="{ref}"/>'
26
+ if isinstance(value, (int, float)):
27
+ return f'<c r="{ref}"><v>{value}</v></c>'
28
+ escaped = (
29
+ str(value)
30
+ .replace("&", "&amp;")
31
+ .replace("<", "&lt;")
32
+ .replace(">", "&gt;")
33
+ .replace('"', "&quot;")
34
+ )
35
+ return f'<c r="{ref}" t="inlineStr"><is><t>{escaped}</t></is></c>'
36
+
37
+
38
+ def sheet_xml(rows: list[list[object]]) -> str:
39
+ row_nodes = []
40
+ for row_idx, row in enumerate(rows, start=1):
41
+ cells = "".join(cell_xml(row_idx, col_idx, value) for col_idx, value in enumerate(row, start=1))
42
+ row_nodes.append(f'<row r="{row_idx}">{cells}</row>')
43
+ return (
44
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
45
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
46
+ f"<sheetData>{''.join(row_nodes)}</sheetData>"
47
+ "</worksheet>"
48
+ )
49
+
50
+
51
+ def workbook_xml(sheet_names: list[str]) -> str:
52
+ sheets = "".join(
53
+ f'<sheet name="{name}" sheetId="{idx}" r:id="rId{idx}"/>'
54
+ for idx, name in enumerate(sheet_names, start=1)
55
+ )
56
+ return (
57
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
58
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
59
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
60
+ f"<sheets>{sheets}</sheets>"
61
+ "</workbook>"
62
+ )
63
+
64
+
65
+ def workbook_rels_xml(sheet_names: list[str]) -> str:
66
+ rels = "".join(
67
+ '<Relationship '
68
+ f'Id="rId{idx}" '
69
+ 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
70
+ f'Target="worksheets/sheet{idx}.xml"/>'
71
+ for idx, _ in enumerate(sheet_names, start=1)
72
+ )
73
+ return (
74
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
75
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
76
+ f"{rels}</Relationships>"
77
+ )
78
+
79
+
80
+ def root_rels_xml() -> str:
81
+ return (
82
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
83
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
84
+ '<Relationship Id="rId1" '
85
+ 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
86
+ 'Target="xl/workbook.xml"/>'
87
+ "</Relationships>"
88
+ )
89
+
90
+
91
+ def content_types_xml(sheet_names: list[str]) -> str:
92
+ overrides = "".join(
93
+ '<Override '
94
+ f'PartName="/xl/worksheets/sheet{idx}.xml" '
95
+ 'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
96
+ for idx, _ in enumerate(sheet_names, start=1)
97
+ )
98
+ return (
99
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
100
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
101
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
102
+ '<Default Extension="xml" ContentType="application/xml"/>'
103
+ '<Override PartName="/xl/workbook.xml" '
104
+ 'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
105
+ f"{overrides}</Types>"
106
+ )
107
+
108
+
109
+ def write_xlsx(path: Path, sheets: dict[str, list[list[object]]]) -> None:
110
+ sheet_names = list(sheets)
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ with ZipFile(path, "w", ZIP_DEFLATED) as zf:
113
+ zf.writestr("[Content_Types].xml", content_types_xml(sheet_names))
114
+ zf.writestr("_rels/.rels", root_rels_xml())
115
+ zf.writestr("xl/workbook.xml", workbook_xml(sheet_names))
116
+ zf.writestr("xl/_rels/workbook.xml.rels", workbook_rels_xml(sheet_names))
117
+ for idx, name in enumerate(sheet_names, start=1):
118
+ zf.writestr(f"xl/worksheets/sheet{idx}.xml", sheet_xml(sheets[name]))
119
+
120
+
121
+ def write_json(path: Path, data: object) -> None:
122
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
123
+
124
+
125
+ def build(output_dir: Path = ROOT) -> list[Path]:
126
+ output_dir.mkdir(parents=True, exist_ok=True)
127
+ created: list[Path] = []
128
+
129
+ simple_values = [
130
+ ["metric", "Q1", "Q2"],
131
+ ["revenue", 100, 120],
132
+ ["cost", 40, 55],
133
+ ]
134
+ rows = [
135
+ {"metric": "gross_profit", "Q1": 60, "Q2": 65},
136
+ {"metric": "margin", "Q1": "60%", "Q2": "54%"},
137
+ ]
138
+ write_json(output_dir / "simple-values.json", simple_values)
139
+ write_json(output_dir / "rows.json", rows)
140
+ created.extend([output_dir / "simple-values.json", output_dir / "rows.json"])
141
+
142
+ write_xlsx(
143
+ output_dir / "simple-grid.xlsx",
144
+ {
145
+ "BenchData": [
146
+ ["metric", "Q1", "Q2", "Q3"],
147
+ ["revenue", 100, 120, 140],
148
+ ["cost", 40, 55, 60],
149
+ ["profit", 60, 65, 80],
150
+ ]
151
+ },
152
+ )
153
+ created.append(output_dir / "simple-grid.xlsx")
154
+
155
+ write_xlsx(
156
+ output_dir / "flat-records.xlsx",
157
+ {
158
+ "Records": [
159
+ ["id", "customer", "amount", "status"],
160
+ [1, "Acme", 120.5, "paid"],
161
+ [2, "Beta", 340.0, "open"],
162
+ [3, "Core", 88.75, "paid"],
163
+ [4, "Delta", 901.1, "open"],
164
+ ]
165
+ },
166
+ )
167
+ created.append(output_dir / "flat-records.xlsx")
168
+
169
+ write_xlsx(
170
+ output_dir / "mixed-engine.xlsx",
171
+ {
172
+ "RecordsLarge": [
173
+ ["id", "amount", "status"],
174
+ *[[idx, idx * 3, "paid" if idx % 2 else "open"] for idx in range(1, 26)],
175
+ ],
176
+ "Cover": [["title", "Mixed engine fixture"], ["owner", "benchmark"]],
177
+ "Model": [["metric", "value"], ["revenue", 1200], ["cost", 700]],
178
+ "Events": [["event_id", "value"], [1, 20], [2, 22], [3, 25]],
179
+ },
180
+ )
181
+ created.append(output_dir / "mixed-engine.xlsx")
182
+
183
+ multi_table_rows = [
184
+ ["T01 Revenue Summary", "", "", "", ""],
185
+ ["", "", "", "", ""],
186
+ ["metric", "Q1", "Q2", "Q3", "total"],
187
+ ["revenue", 100, 120, 140, 360],
188
+ ["cost", 40, 55, 60, 155],
189
+ ["profit", 60, 65, 80, 205],
190
+ ["", "", "", "", ""],
191
+ ["", "", "", "", ""],
192
+ ["ratio", "Q1", "Q2", "Q3", "trend"],
193
+ ["margin", "60%", "54%", "57%", "up"],
194
+ ["cost_ratio", "40%", "46%", "43%", "down"],
195
+ ]
196
+ write_xlsx(output_dir / "multi-table-layout.xlsx", {"MultiTable": multi_table_rows})
197
+ created.append(output_dir / "multi-table-layout.xlsx")
198
+
199
+ return created
200
+
201
+
202
+ def main() -> int:
203
+ parser = argparse.ArgumentParser()
204
+ parser.add_argument("--output-dir", type=Path, default=ROOT)
205
+ args = parser.parse_args()
206
+ created = build(args.output_dir)
207
+ for path in created:
208
+ print(path)
209
+ return 0
210
+
211
+
212
+ if __name__ == "__main__":
213
+ raise SystemExit(main())
@@ -0,0 +1,12 @@
1
+ [
2
+ {
3
+ "metric": "gross_profit",
4
+ "Q1": 60,
5
+ "Q2": 65
6
+ },
7
+ {
8
+ "metric": "margin",
9
+ "Q1": "60%",
10
+ "Q2": "54%"
11
+ }
12
+ ]
@@ -0,0 +1,17 @@
1
+ [
2
+ [
3
+ "metric",
4
+ "Q1",
5
+ "Q2"
6
+ ],
7
+ [
8
+ "revenue",
9
+ 100,
10
+ 120
11
+ ],
12
+ [
13
+ "cost",
14
+ 40,
15
+ 55
16
+ ]
17
+ ]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from scripts import benchmark_mbs_cli
10
+
11
+
12
+ class BenchmarkRunnerTests(unittest.TestCase):
13
+ def test_case_catalog_has_exactly_50_ranked_cases(self) -> None:
14
+ cases = benchmark_mbs_cli.load_cases(benchmark_mbs_cli.DEFAULT_CASES)
15
+
16
+ self.assertEqual(len(cases), 50)
17
+ self.assertEqual([case["rank"] for case in cases], list(range(1, 51)))
18
+ self.assertEqual(cases[0]["case_id"], "001")
19
+ self.assertEqual(cases[-1]["case_id"], "050")
20
+
21
+ def test_dry_run_writes_summary_html_and_case_artifacts(self) -> None:
22
+ with tempfile.TemporaryDirectory() as tmp:
23
+ report_root = Path(tmp)
24
+ result = subprocess.run(
25
+ [
26
+ "./.venv/bin/python",
27
+ "scripts/benchmark_mbs_cli.py",
28
+ "--dry-run",
29
+ "--no-publish-results",
30
+ "--skip-fixture-build",
31
+ "--run-id",
32
+ "unit",
33
+ "--rank-max",
34
+ "2",
35
+ "--report-root",
36
+ str(report_root),
37
+ ],
38
+ text=True,
39
+ capture_output=True,
40
+ check=False,
41
+ )
42
+
43
+ self.assertEqual(result.returncode, 0, result.stderr)
44
+ run_dir = report_root / "mbs-cli-benchmark-unit"
45
+ self.assertTrue((run_dir / "summary.json").exists())
46
+ self.assertTrue((run_dir / "report.html").exists())
47
+ self.assertTrue((run_dir / "maybesheet_publish.json").exists())
48
+ self.assertTrue((run_dir / "cases" / "001-workbook-list-worksheets" / "verdict.json").exists())
49
+
50
+ summary = json.loads((run_dir / "summary.json").read_text(encoding="utf-8"))
51
+ self.assertEqual(summary["total"], 2)
52
+ self.assertEqual(summary["status"], "PASS")
53
+ self.assertEqual(summary["maybesheet"]["status"], "skipped")
54
+
55
+ def test_maybesheet_result_sheets_are_summary_details_and_html(self) -> None:
56
+ cases = benchmark_mbs_cli.load_cases(benchmark_mbs_cli.DEFAULT_CASES)[:2]
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ run_dir = Path(tmp) / "run"
59
+ run_dir.mkdir()
60
+ run = benchmark_mbs_cli.BenchmarkRun(
61
+ run_id="unit",
62
+ started_at="2026-07-02T00:00:00+00:00",
63
+ run_dir=run_dir,
64
+ mbs="./.venv/bin/mbs",
65
+ base_url=None,
66
+ context={"READ_DOC_ID": "doc1", "READ_TYPICAL_GID": "19"},
67
+ )
68
+ for case in cases:
69
+ case_dir = run_dir / "cases" / benchmark_mbs_cli.case_slug(case)
70
+ case_dir.mkdir(parents=True)
71
+ run.results.append(
72
+ benchmark_mbs_cli.CaseResult(
73
+ case=case,
74
+ case_dir=case_dir,
75
+ command=["mbs", *case["args"]],
76
+ status="success",
77
+ exit_code=0,
78
+ duration_ms=10,
79
+ response={"success": True},
80
+ key_response={"success": True, "document_id": "doc1"},
81
+ checks=[{"name": "exit_code", "pass": True}],
82
+ )
83
+ )
84
+ summary = benchmark_mbs_cli.build_summary(run, 0, "0.7.0")
85
+ benchmark_mbs_cli.generate_html_report(run, summary)
86
+
87
+ sheets = benchmark_mbs_cli.build_result_sheets(run, summary)
88
+
89
+ self.assertEqual(list(sheets), ["Summary", "Details", "HTMLReport"])
90
+ self.assertEqual(len(sheets["Details"]), 3)
91
+ self.assertEqual(sheets["Details"][0][0], "rank")
92
+ self.assertEqual(sheets["HTMLReport"][0], ["section", "item", "value"])
93
+
94
+ def test_stdout_contains_check_and_key_response_capture(self) -> None:
95
+ result = benchmark_mbs_cli.CommandResult(
96
+ command=["mbs", "update", "--dry-run"],
97
+ exit_code=0,
98
+ stdout="python -m pip install --upgrade maybeai-sheet-cli==0.7.0\n",
99
+ stderr="",
100
+ duration_ms=5,
101
+ )
102
+
103
+ checks = benchmark_mbs_cli.evaluate_case_checks(
104
+ {
105
+ "expected_exit_code": 0,
106
+ "expected_json": False,
107
+ "expected_success": None,
108
+ "checks": [{"type": "stdout_contains", "value": "maybeai-sheet-cli==0.7.0"}],
109
+ },
110
+ result,
111
+ None,
112
+ {},
113
+ {},
114
+ )
115
+
116
+ self.assertTrue(all(check["pass"] for check in checks))
117
+
118
+ def test_case_env_unset_removes_proxy_for_command(self) -> None:
119
+ with tempfile.TemporaryDirectory() as tmp:
120
+ script = Path(tmp) / "env_probe.py"
121
+ script.write_text(
122
+ "import os, sys\n"
123
+ "sys.stdout.write('missing' if 'ALL_PROXY' not in os.environ else os.environ['ALL_PROXY'])\n",
124
+ encoding="utf-8",
125
+ )
126
+ case = {
127
+ "rank": 1,
128
+ "case_id": "env",
129
+ "name": "env probe",
130
+ "category": "unit",
131
+ "args": [str(script)],
132
+ "expected_json": False,
133
+ "expected_success": None,
134
+ "env_unset": ["ALL_PROXY"],
135
+ "checks": [{"type": "stdout_contains", "value": "missing"}],
136
+ }
137
+ run_dir = Path(tmp) / "run"
138
+ run = benchmark_mbs_cli.BenchmarkRun(
139
+ run_id="unit",
140
+ started_at="2026-07-02T00:00:00+00:00",
141
+ run_dir=run_dir,
142
+ mbs="./.venv/bin/python",
143
+ base_url=None,
144
+ context={"RUN_DIR": str(run_dir)},
145
+ )
146
+
147
+ result = benchmark_mbs_cli.run_case(case, run, {"ALL_PROXY": "socks5://127.0.0.1:9"}, False)
148
+
149
+ self.assertEqual(result.status, "success")
150
+ self.assertEqual(result.stdout, "missing")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main()
@@ -1126,6 +1126,41 @@ class CLITests(unittest.TestCase):
1126
1126
  self.assertEqual(endpoint, "/api/v1/excel/recalculate_formulas")
1127
1127
  self.assertEqual(payload["worksheet_name"], "Model")
1128
1128
 
1129
+ def test_workbook_calculate_recalculates_whole_workbook(self) -> None:
1130
+ with patch("maybeai_sheet.commands.sheet.MaybeAIClient.post_json") as post_json:
1131
+ post_json.return_value = {"success": True}
1132
+ result = self.runner.invoke(
1133
+ app,
1134
+ ["workbook", "calculate", "--doc-id", "abc123"],
1135
+ env=self.env,
1136
+ )
1137
+
1138
+ self.assertEqual(result.exit_code, 0, result.output)
1139
+ endpoint, payload = post_json.call_args.args
1140
+ self.assertEqual(endpoint, "/api/v1/excel/recalculate_formulas")
1141
+ self.assertNotIn("worksheet_name", payload)
1142
+
1143
+ def test_excel_worksheet_calculate_recalculates_worksheet(self) -> None:
1144
+ with patch("maybeai_sheet.commands.sheet.MaybeAIClient.post_json") as post_json:
1145
+ post_json.return_value = {"success": True}
1146
+ result = self.runner.invoke(
1147
+ app,
1148
+ [
1149
+ "excel-worksheet",
1150
+ "calculate",
1151
+ "--doc-id",
1152
+ "abc123",
1153
+ "--worksheet-name",
1154
+ "Model",
1155
+ ],
1156
+ env=self.env,
1157
+ )
1158
+
1159
+ self.assertEqual(result.exit_code, 0, result.output)
1160
+ endpoint, payload = post_json.call_args.args
1161
+ self.assertEqual(endpoint, "/api/v1/excel/recalculate_formulas")
1162
+ self.assertEqual(payload["worksheet_name"], "Model")
1163
+
1129
1164
  def test_workbook_import_alias_uses_import_endpoint(self) -> None:
1130
1165
  with tempfile.NamedTemporaryFile(suffix=".xlsx") as temp_file:
1131
1166
  with patch("maybeai_sheet.commands.workbook.MaybeAIClient.upload_file") as upload_file:
@@ -540,6 +540,34 @@ class Phase2CLITests(unittest.TestCase):
540
540
  self.assertEqual(lineage_payload["targets"], [{"worksheet_name": "Model", "cell": "E2"}])
541
541
  self.assertNotIn("compile", help_result.output)
542
542
 
543
+ def test_excel_worksheet_range_calculate_uses_formula_calculate_endpoint(self) -> None:
544
+ with patch("maybeai_sheet.commands.range.MaybeAIClient.post_json") as post_json:
545
+ post_json.return_value = {"success": True}
546
+ result = self.runner.invoke(
547
+ app,
548
+ [
549
+ "excel-worksheet",
550
+ "range",
551
+ "calculate",
552
+ "--doc-id",
553
+ "abc123",
554
+ "--worksheet-name",
555
+ "Model",
556
+ "--cell",
557
+ "E2",
558
+ "--formula",
559
+ "=SUM(B2:D2)",
560
+ ],
561
+ env=self.env,
562
+ )
563
+
564
+ self.assertEqual(result.exit_code, 0, result.output)
565
+ endpoint, payload = post_json.call_args.args
566
+ self.assertEqual(endpoint, "/api/v1/excel/calc-formula")
567
+ self.assertEqual(payload["cellAddress"], "E2")
568
+ self.assertEqual(payload["formula"], "=SUM(B2:D2)")
569
+ self.assertEqual(payload["worksheet_name"], "Model")
570
+
543
571
  def test_cell_lineage_uses_verified_endpoint(self) -> None:
544
572
  with patch("maybeai_sheet.commands.cell.MaybeAIClient.post_json") as post_json:
545
573
  post_json.return_value = {"success": True}