maybeai-sheet-cli 0.19.2__tar.gz → 0.20.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 (65) hide show
  1. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/PKG-INFO +17 -2
  2. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/README.md +16 -1
  3. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/pyproject.toml +1 -1
  4. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/db_table.py +57 -6
  5. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/style.py +252 -36
  6. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/endpoints.py +3 -2
  7. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_cli.py +5 -1
  8. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_endpoints.py +7 -1
  9. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_phase2_cli.py +277 -7
  10. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_two_engine_cli.py +62 -7
  11. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/.gitignore +0 -0
  12. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/__init__.py +0 -0
  13. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/chart_config.py +0 -0
  14. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/chart_style_summary.py +0 -0
  15. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/cli.py +0 -0
  16. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/client.py +0 -0
  17. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/__init__.py +0 -0
  18. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/_phase2.py +0 -0
  19. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/cell.py +0 -0
  20. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/chart.py +0 -0
  21. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/column.py +0 -0
  22. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/dashboard.py +0 -0
  23. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/excel_table.py +0 -0
  24. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/excel_worksheet.py +0 -0
  25. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/file.py +0 -0
  26. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/formula.py +0 -0
  27. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/history.py +0 -0
  28. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/image.py +0 -0
  29. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/media.py +0 -0
  30. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/pivot.py +0 -0
  31. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/range.py +0 -0
  32. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/raw.py +0 -0
  33. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/row.py +0 -0
  34. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/share.py +0 -0
  35. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/sheet.py +0 -0
  36. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/table.py +0 -0
  37. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/workbook.py +0 -0
  38. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/commands/worksheet.py +0 -0
  39. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/config.py +0 -0
  40. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/dashboard_interactions.py +0 -0
  41. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/dashboard_plan.py +0 -0
  42. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/errors.py +0 -0
  43. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/formatters.py +0 -0
  44. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/models/__init__.py +0 -0
  45. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/models/share.py +0 -0
  46. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/models/sheet.py +0 -0
  47. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/models/workbook.py +0 -0
  48. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/options.py +0 -0
  49. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/protocol.py +0 -0
  50. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/resolver.py +0 -0
  51. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/table_resolver.py +0 -0
  52. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/src/maybeai_sheet/update.py +0 -0
  53. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/build_fixtures.py +0 -0
  54. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
  55. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
  56. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
  57. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/rows.json +0 -0
  58. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
  59. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/fixtures/benchmarks/simple-values.json +0 -0
  60. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_benchmark_runner.py +0 -0
  61. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_dashboard_plan.py +0 -0
  62. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_formatters.py +0 -0
  63. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_resolver.py +0 -0
  64. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/tests/test_update.py +0 -0
  65. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.20.0}/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.2
3
+ Version: 0.20.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
@@ -641,7 +641,7 @@ mbs db-table insert --doc-id abc123 --name orders-large --rows rows.json # API
641
641
  mbs db-table create --doc-id abc123 --name Orders --rows orders.json # API
642
642
  mbs db-table create-from-query --doc-id abc123 --name OrderSummary --sql-file order_summary.sql # API
643
643
  mbs db-table create-from-range --doc-id target123 --name R_OrderLines_Store1 --source-doc-id source123 --worksheet-name "1店" --range A2:AR423 --header-row 0 --use-header-names --if-exists adopt --verify # API
644
- mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # PLAN
644
+ mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # API
645
645
  mbs db-table update --doc-id abc123 --name Orders --row-index 42 --rows row_patch.json # PLAN
646
646
  mbs db-table update --doc-id abc123 --name Orders --where '{"status":"open"}' --rows status_patch.json # PLAN
647
647
  mbs db-table update --doc-id abc123 --name Orders --column-index 3 --column-name status # PLAN
@@ -751,6 +751,21 @@ is also CLI-composed: it reads a source worksheet range, reshapes header/data
751
751
  rows, then creates a PG/SheetTable on the target workbook. Use a keyset-style
752
752
  backend contract for deeper pagination when it becomes available.
753
753
 
754
+ `db-table update` currently supports keyed row updates through the routed
755
+ `update_range_by_lookup` backend contract:
756
+
757
+ ```bash
758
+ mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json
759
+ ```
760
+
761
+ The `--key` column must be present in each row object and is used to match target
762
+ rows, for example `order_id`, `sku`, or `id`. Patch files may include only the
763
+ columns that should change; omitted columns are left unchanged. Blank values
764
+ overwrite existing values only when `--override` is passed. Positional updates
765
+ with `--row-index`, column-level updates with `--column-index` / `--column-name`,
766
+ and JSON-filter updates with `--where` remain planned until the backend exposes a
767
+ stable native db-table contract for those modes.
768
+
754
769
  Refactor planning is tracked separately from this README:
755
770
  `docs/superpowers/plans/2026-07-01-mbs-two-engine-command-refactor.md`.
756
771
 
@@ -617,7 +617,7 @@ mbs db-table insert --doc-id abc123 --name orders-large --rows rows.json # API
617
617
  mbs db-table create --doc-id abc123 --name Orders --rows orders.json # API
618
618
  mbs db-table create-from-query --doc-id abc123 --name OrderSummary --sql-file order_summary.sql # API
619
619
  mbs db-table create-from-range --doc-id target123 --name R_OrderLines_Store1 --source-doc-id source123 --worksheet-name "1店" --range A2:AR423 --header-row 0 --use-header-names --if-exists adopt --verify # API
620
- mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # PLAN
620
+ mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # API
621
621
  mbs db-table update --doc-id abc123 --name Orders --row-index 42 --rows row_patch.json # PLAN
622
622
  mbs db-table update --doc-id abc123 --name Orders --where '{"status":"open"}' --rows status_patch.json # PLAN
623
623
  mbs db-table update --doc-id abc123 --name Orders --column-index 3 --column-name status # PLAN
@@ -727,6 +727,21 @@ is also CLI-composed: it reads a source worksheet range, reshapes header/data
727
727
  rows, then creates a PG/SheetTable on the target workbook. Use a keyset-style
728
728
  backend contract for deeper pagination when it becomes available.
729
729
 
730
+ `db-table update` currently supports keyed row updates through the routed
731
+ `update_range_by_lookup` backend contract:
732
+
733
+ ```bash
734
+ mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json
735
+ ```
736
+
737
+ The `--key` column must be present in each row object and is used to match target
738
+ rows, for example `order_id`, `sku`, or `id`. Patch files may include only the
739
+ columns that should change; omitted columns are left unchanged. Blank values
740
+ overwrite existing values only when `--override` is passed. Positional updates
741
+ with `--row-index`, column-level updates with `--column-index` / `--column-name`,
742
+ and JSON-filter updates with `--where` remain planned until the backend exposes a
743
+ stable native db-table contract for those modes.
744
+
730
745
  Refactor planning is tracked separately from this README:
731
746
  `docs/superpowers/plans/2026-07-01-mbs-two-engine-command-refactor.md`.
732
747
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "maybeai-sheet-cli"
7
- version = "0.19.2"
7
+ version = "0.20.0"
8
8
  description = "CLI for common MaybeAI spreadsheet operations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1162,9 +1162,6 @@ import data:
1162
1162
  DB_TABLE_UPDATE_EXAMPLE = """Example:
1163
1163
 
1164
1164
  mbs db-table update --doc-id <doc_id> --name Orders --key order_id --rows orders_patch.json
1165
- mbs db-table update --doc-id <doc_id> --name Orders --row-index 42 --rows row_patch.json
1166
- mbs db-table update --doc-id <doc_id> --name Orders --where '{"status":"open"}' --rows status_patch.json
1167
- mbs db-table update --doc-id <doc_id> --name Orders --column-index 3 --column-name status
1168
1165
 
1169
1166
  `orders_patch.json` should contain row objects keyed by --key. Positional
1170
1167
  updates use 1-based --row-index and --column-index when the backend exposes a
@@ -1574,12 +1571,16 @@ def create_from_range(
1574
1571
 
1575
1572
  @app.command(
1576
1573
  "update",
1577
- help="Planned: update PG/SheetTable-backed rows from JSON row data.",
1574
+ help="Update PG/SheetTable-backed rows by lookup key from JSON row data.",
1578
1575
  epilog=DB_TABLE_UPDATE_EXAMPLE,
1579
1576
  )
1580
1577
  def update(
1578
+ ctx: typer.Context,
1581
1579
  doc_id: Optional[str] = typer.Option(None, "--doc-id", help="MaybeAI document ID."),
1580
+ url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
1581
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
1582
1582
  name: Optional[str] = typer.Option(None, "--name", help="PG-backed table name."),
1583
+ backend_id: Optional[str] = typer.Option(None, "--backend-id", help="Advanced backend table id."),
1583
1584
  key: Optional[str] = typer.Option(None, "--key", help="Unique key column for matching rows."),
1584
1585
  row_index: Optional[int] = typer.Option(None, "--row-index", min=1, help="1-based SheetTable row index to update."),
1585
1586
  column_index: Optional[int] = typer.Option(None, "--column-index", min=1, help="1-based SheetTable column index to update."),
@@ -1593,10 +1594,60 @@ def update(
1593
1594
  resolve_path=True,
1594
1595
  help="Path to JSON rows payload.",
1595
1596
  ),
1597
+ override: bool = typer.Option(False, "--override", help="Allow blank values in --rows to overwrite existing cells."),
1598
+ skip_recalculation: bool = typer.Option(False, "--skip-recalculation", help="Skip immediate recalculation."),
1599
+ output: Optional[str] = output_option(),
1600
+ verbose: Optional[bool] = verbose_option(),
1601
+ timeout: Optional[float] = timeout_option(),
1596
1602
  ) -> None:
1597
- _ = (doc_id, name, key, row_index, column_index, column_name, where, rows)
1598
1603
  try:
1599
- _planned_db_table_error("update", DB_TABLE_UPDATE_EXAMPLE)
1604
+ if row_index is not None or column_index is not None or column_name is not None or where is not None:
1605
+ raise UsageError(
1606
+ "db-table update currently supports keyed row updates only. "
1607
+ "Use --key with --rows; --row-index, --column-index, --column-name, and --where are planned."
1608
+ )
1609
+ if not key or not key.strip():
1610
+ raise UsageError("Provide --key for db-table update.")
1611
+ if rows is None:
1612
+ raise UsageError("Provide --rows for db-table update.")
1613
+ state = _state(
1614
+ ctx,
1615
+ doc_id=doc_id,
1616
+ url=url,
1617
+ uri=uri,
1618
+ output=output,
1619
+ verbose=verbose,
1620
+ timeout=timeout,
1621
+ )
1622
+ workbook_target = resolve_target(state)
1623
+ client = MaybeAIClient(state)
1624
+ resolved, target = _resolve_db_table(
1625
+ client=client,
1626
+ target=workbook_target,
1627
+ name=name,
1628
+ backend_id=backend_id,
1629
+ )
1630
+ endpoint = "/api/v1/excel/update_range_by_lookup"
1631
+ response = client.post_json(
1632
+ endpoint,
1633
+ {
1634
+ "uri": target.uri,
1635
+ "worksheet_name": resolved.worksheet_name,
1636
+ "data": _load_rows(rows),
1637
+ "on": [key.strip()],
1638
+ "override": override,
1639
+ "skip_recalculation": skip_recalculation,
1640
+ },
1641
+ )
1642
+ render_output(
1643
+ build_command_output(
1644
+ endpoint=endpoint,
1645
+ target=target,
1646
+ result=response,
1647
+ metadata=get_endpoint_metadata("db-table.update") if state.verbose else None,
1648
+ ),
1649
+ state.output,
1650
+ )
1600
1651
  except Exception as error: # pragma: no cover - CLI boundary
1601
1652
  handle_cli_error(error)
1602
1653
 
@@ -51,6 +51,28 @@ _BEAUTIFY_DEFAULTS: dict[str, object] = {
51
51
  "preset": "clean_data_table",
52
52
  "hideGridlines": True,
53
53
  }
54
+ _BEAUTIFY_BODY_BANDING: dict[str, object] = {
55
+ "enabled": True,
56
+ "oddRowBackground": "#FFFFFF",
57
+ "evenRowBackground": "#F8FAFC",
58
+ }
59
+ _BEAUTIFY_COLUMN_POLICY: dict[str, object] = {
60
+ "backgroundBySemanticType": False,
61
+ "allowFontColorBySemanticType": True,
62
+ "allowFormatBySemanticType": True,
63
+ "allowAlignmentBySemanticType": True,
64
+ }
65
+ _BEAUTIFY_CONFIG_KEYS = frozenset(
66
+ {
67
+ "defaults",
68
+ "layout",
69
+ "bodyBanding",
70
+ "columnPolicy",
71
+ "headerStyle",
72
+ "categoryStyles",
73
+ "sampling",
74
+ }
75
+ )
54
76
  _BEAUTIFY_SAMPLE_RANGE = "A1:ZZ20"
55
77
  _BEAUTIFY_HEADER_STYLE: dict[str, object] = {
56
78
  "bold": True,
@@ -78,47 +100,40 @@ _BEAUTIFY_TEXT_STYLE: dict[str, object] = {
78
100
  "format": "text",
79
101
  "horizontal": "left",
80
102
  "vertical": "middle",
81
- "bg_color": "#FFFFFF",
82
103
  }
83
104
  _BEAUTIFY_DATE_STYLE: dict[str, object] = {
84
105
  "format": "date",
85
106
  "format_code": "yyyy-mm-dd",
86
107
  "horizontal": "center",
87
- "bg_color": "#F8FAFC",
88
108
  }
89
109
  _BEAUTIFY_CURRENCY_STYLE: dict[str, object] = {
90
110
  "format": "currency",
91
111
  "format_code": "$#,##0.00",
92
112
  "horizontal": "right",
93
113
  "font_color": "#065F46",
94
- "bg_color": "#ECFDF5",
95
114
  }
96
115
  _BEAUTIFY_PERCENT_STYLE: dict[str, object] = {
97
116
  "format": "percent",
98
117
  "format_code": "0.00%",
99
118
  "horizontal": "right",
100
119
  "font_color": "#1D4ED8",
101
- "bg_color": "#EFF6FF",
102
120
  }
103
121
  _BEAUTIFY_INTEGER_STYLE: dict[str, object] = {
104
122
  "format": "integer",
105
123
  "format_code": "#,##0",
106
124
  "horizontal": "right",
107
125
  "font_color": "#581C87",
108
- "bg_color": "#F5F3FF",
109
126
  }
110
127
  _BEAUTIFY_NUMBER_STYLE: dict[str, object] = {
111
128
  "format": "number",
112
129
  "format_code": "0.00",
113
130
  "horizontal": "right",
114
131
  "font_color": "#374151",
115
- "bg_color": "#F9FAFB",
116
132
  }
117
133
  _BEAUTIFY_STATUS_STYLE: dict[str, object] = {
118
134
  "format": "text",
119
135
  "horizontal": "left",
120
136
  "font_color": "#92400E",
121
- "bg_color": "#FEF3C7",
122
137
  }
123
138
  _BEAUTIFY_STYLE_BY_CATEGORY: dict[str, dict[str, object]] = {
124
139
  "text": _BEAUTIFY_TEXT_STYLE,
@@ -129,6 +144,47 @@ _BEAUTIFY_STYLE_BY_CATEGORY: dict[str, dict[str, object]] = {
129
144
  "number": _BEAUTIFY_NUMBER_STYLE,
130
145
  "status": _BEAUTIFY_STATUS_STYLE,
131
146
  }
147
+
148
+
149
+ def _default_beautify_style_policy() -> dict[str, object]:
150
+ return {
151
+ "defaults": dict(_BEAUTIFY_DEFAULTS),
152
+ "bodyBanding": dict(_BEAUTIFY_BODY_BANDING),
153
+ "columnPolicy": dict(_BEAUTIFY_COLUMN_POLICY),
154
+ }
155
+
156
+
157
+ def _merge_dict(base: dict[str, object], override: object, *, option: str) -> dict[str, object]:
158
+ if override is None:
159
+ return dict(base)
160
+ if not isinstance(override, dict):
161
+ raise UsageError(f"{option} must be a JSON object.")
162
+ merged = dict(base)
163
+ merged.update(override)
164
+ return merged
165
+
166
+
167
+ def _beautify_config(config_path: Path | None) -> tuple[dict[str, object] | None, str | None]:
168
+ if config_path is None:
169
+ return None, None
170
+ config = load_json_object_argument(config_path, option="--config")
171
+ unsupported = sorted(set(config) - _BEAUTIFY_CONFIG_KEYS)
172
+ if unsupported:
173
+ raise UsageError(f"--config contains unsupported keys: {', '.join(unsupported)}.")
174
+ return config, str(config_path)
175
+
176
+
177
+ def _beautify_style_policy(config: dict[str, object] | None = None) -> dict[str, object]:
178
+ policy = _default_beautify_style_policy()
179
+ if not config:
180
+ return policy
181
+ if "defaults" in config:
182
+ policy["defaults"] = _merge_dict(policy["defaults"], config["defaults"], option="--config defaults") # type: ignore[arg-type]
183
+ if "bodyBanding" in config:
184
+ policy["bodyBanding"] = _merge_dict(policy["bodyBanding"], config["bodyBanding"], option="--config bodyBanding") # type: ignore[arg-type]
185
+ if "columnPolicy" in config:
186
+ policy["columnPolicy"] = _merge_dict(policy["columnPolicy"], config["columnPolicy"], option="--config columnPolicy") # type: ignore[arg-type]
187
+ return policy
132
188
  _BEAUTIFY_KEYWORDS: dict[str, tuple[str, ...]] = {
133
189
  "date": (
134
190
  "date",
@@ -580,6 +636,39 @@ def _beautify_workbook_payload(
580
636
  }
581
637
 
582
638
 
639
+ def _is_styleable_worksheet(worksheet: dict[str, object]) -> bool:
640
+ worksheet_name = _worksheet_name(worksheet)
641
+ if not worksheet_name:
642
+ return False
643
+ row_count = _int_payload_value(worksheet, ("row_count", "rows", "total_rows", "max_row"))
644
+ column_count = _int_payload_value(
645
+ worksheet,
646
+ ("column_count", "col_count", "columns", "total_columns", "max_column", "max_col"),
647
+ )
648
+ return not ((row_count is not None and row_count <= 0) or (column_count is not None and column_count <= 0))
649
+
650
+
651
+ def _select_beautify_worksheets(target, worksheet_response: dict[str, object]) -> list[dict[str, object]]:
652
+ styleable = [worksheet for worksheet in _worksheet_items(worksheet_response) if _is_styleable_worksheet(worksheet)]
653
+ if not styleable:
654
+ if target.worksheet_name:
655
+ raise UsageError(f"No styleable worksheet found for --worksheet-name {target.worksheet_name!r}.")
656
+ raise UsageError("No styleable worksheets found. The workbook may be empty or missing dimensions.")
657
+ if target.worksheet_name:
658
+ selected = [worksheet for worksheet in styleable if _worksheet_name(worksheet) == target.worksheet_name]
659
+ if not selected:
660
+ raise UsageError(f"No styleable worksheet found for --worksheet-name {target.worksheet_name!r}.")
661
+ return selected[:1]
662
+ if target.gid is not None:
663
+ selected = [worksheet for worksheet in styleable if _worksheet_gid(worksheet) == target.gid]
664
+ if not selected:
665
+ raise UsageError(f"No styleable worksheet found for gid {target.gid}.")
666
+ return selected[:1]
667
+ if len(styleable) == 1:
668
+ return styleable
669
+ raise UsageError("Workbook has multiple styleable worksheets. Provide --gid or --worksheet-name.")
670
+
671
+
583
672
  def _bounded_table_range_from_shape(column_count: int, row_count: int) -> str:
584
673
  if column_count <= 0 or row_count <= 0:
585
674
  raise UsageError("Cannot beautify an empty worksheet.")
@@ -867,6 +956,7 @@ def _excel_column_style_operations(
867
956
  continue
868
957
  operations.append(
869
958
  {
959
+ "kind": "semantic_column_style",
870
960
  "range_addresses": [
871
961
  f"{_column_letters_from_index(run_start)}2:{_column_letters_from_index(one_based_index - 1)}{data_last_row}"
872
962
  ],
@@ -877,6 +967,7 @@ def _excel_column_style_operations(
877
967
  run_style = style
878
968
  operations.append(
879
969
  {
970
+ "kind": "semantic_column_style",
880
971
  "range_addresses": [
881
972
  f"{_column_letters_from_index(run_start)}2:{_column_letters_from_index(len(headers))}{data_last_row}"
882
973
  ],
@@ -886,7 +977,51 @@ def _excel_column_style_operations(
886
977
  return operations
887
978
 
888
979
 
889
- def _excel_beautify_plan(worksheet: dict[str, object], read_response: dict[str, object] | None = None) -> dict[str, object]:
980
+ def _excel_body_banding_operations(
981
+ *,
982
+ column_count: int,
983
+ last_row: int,
984
+ style_policy: dict[str, object],
985
+ ) -> list[dict[str, object]]:
986
+ banding = style_policy.get("bodyBanding")
987
+ if not isinstance(banding, dict) or banding.get("enabled") is False:
988
+ return []
989
+ if column_count <= 0 or last_row < 2:
990
+ return []
991
+ odd_background = str(banding.get("oddRowBackground", "#FFFFFF"))
992
+ even_background = str(banding.get("evenRowBackground", "#F8FAFC"))
993
+ last_column = _column_letters_from_index(column_count)
994
+ odd_ranges: list[str] = []
995
+ even_ranges: list[str] = []
996
+ for row_number in range(2, last_row + 1):
997
+ target = odd_ranges if (row_number - 2) % 2 == 0 else even_ranges
998
+ target.append(f"A{row_number}:{last_column}{row_number}")
999
+ operations: list[dict[str, object]] = []
1000
+ if odd_ranges:
1001
+ operations.append(
1002
+ {
1003
+ "kind": "body_banding",
1004
+ "range_addresses": odd_ranges,
1005
+ "style": {"bg_color": odd_background},
1006
+ }
1007
+ )
1008
+ if even_ranges:
1009
+ operations.append(
1010
+ {
1011
+ "kind": "body_banding",
1012
+ "range_addresses": even_ranges,
1013
+ "style": {"bg_color": even_background},
1014
+ }
1015
+ )
1016
+ return operations
1017
+
1018
+
1019
+ def _excel_beautify_plan(
1020
+ worksheet: dict[str, object],
1021
+ read_response: dict[str, object] | None = None,
1022
+ *,
1023
+ style_policy: dict[str, object] | None = None,
1024
+ ) -> dict[str, object]:
890
1025
  headers = _header_values_from_read_response(read_response or {})
891
1026
  if not headers:
892
1027
  column_count = _int_payload_value(
@@ -902,10 +1037,12 @@ def _excel_beautify_plan(worksheet: dict[str, object], read_response: dict[str,
902
1037
  classifications = _excel_column_classifications(headers, read_response)
903
1038
  last_row = _range_data_last_row(table_range)
904
1039
  header_range = f"A1:{_column_letters_from_index(column_count)}1"
1040
+ resolved_style_policy = style_policy or _default_beautify_style_policy()
905
1041
  return {
906
1042
  "worksheet_name": _worksheet_name(worksheet),
907
1043
  "engine": "excelize",
908
1044
  "table_range": table_range,
1045
+ "style_policy": resolved_style_policy,
909
1046
  "headers": headers,
910
1047
  "columns": [
911
1048
  _column_classification_plan(
@@ -917,14 +1054,17 @@ def _excel_beautify_plan(worksheet: dict[str, object], read_response: dict[str,
917
1054
  ],
918
1055
  "operations": [
919
1056
  {
1057
+ "kind": "freeze_header",
920
1058
  "endpoint": "/api/v1/excel/freeze_panes",
921
1059
  "payload": {"freeze_rows": 1, "freeze_columns": 0},
922
1060
  },
923
1061
  {
1062
+ "kind": "filter",
924
1063
  "endpoint": "/api/v1/excel/set_auto_filter",
925
1064
  "payload": {"auto_filter": {"ref": table_range}},
926
1065
  },
927
1066
  {
1067
+ "kind": "header_style",
928
1068
  "endpoint": "/api/v1/excel/batch_set_cell_style",
929
1069
  "payload": {
930
1070
  "range_addresses": [header_range],
@@ -933,8 +1073,21 @@ def _excel_beautify_plan(worksheet: dict[str, object], read_response: dict[str,
933
1073
  },
934
1074
  *[
935
1075
  {
1076
+ "kind": operation.get("kind", "body_banding"),
1077
+ "endpoint": "/api/v1/excel/batch_set_cell_style",
1078
+ "payload": {key: value for key, value in operation.items() if key != "kind"},
1079
+ }
1080
+ for operation in _excel_body_banding_operations(
1081
+ column_count=column_count,
1082
+ last_row=last_row,
1083
+ style_policy=resolved_style_policy,
1084
+ )
1085
+ ],
1086
+ *[
1087
+ {
1088
+ "kind": operation.get("kind", "semantic_column_style"),
936
1089
  "endpoint": "/api/v1/excel/batch_set_cell_style",
937
- "payload": operation,
1090
+ "payload": {key: value for key, value in operation.items() if key != "kind"},
938
1091
  }
939
1092
  for operation in _excel_column_style_operations(headers, classifications, last_row)
940
1093
  ],
@@ -942,6 +1095,65 @@ def _excel_beautify_plan(worksheet: dict[str, object], read_response: dict[str,
942
1095
  }
943
1096
 
944
1097
 
1098
+ def _bounded_verify_range(table_range: object) -> str:
1099
+ if not isinstance(table_range, str):
1100
+ return "A1:Z20"
1101
+ end = _range_end(table_range)
1102
+ if end is None:
1103
+ return "A1:Z20"
1104
+ column_count = min(column_index_from_letters(end[0]), column_index_from_letters("Z"))
1105
+ row_count = min(end[1], 20)
1106
+ return f"A1:{_column_letters_from_index(column_count)}{row_count}"
1107
+
1108
+
1109
+ def _excel_beautify_verify(client: MaybeAIClient, *, target, plan: dict[str, object]) -> dict[str, object]:
1110
+ worksheet_name = plan.get("worksheet_name")
1111
+ try:
1112
+ read_payload = base_payload(
1113
+ SimpleNamespace(uri=target.uri, worksheet_name=worksheet_name if isinstance(worksheet_name, str) else None)
1114
+ ) | {
1115
+ "range_address": _bounded_verify_range(plan.get("table_range")),
1116
+ "value_render_option": "FORMATTED_VALUE",
1117
+ }
1118
+ read_response = client.post_json("/api/v1/excel/read_sheet", read_payload)
1119
+ return {
1120
+ "success": bool(read_response.get("success", True)),
1121
+ "scope": "worksheet_sample",
1122
+ "endpoint": "/api/v1/excel/read_sheet",
1123
+ "range_address": read_payload["range_address"],
1124
+ "worksheet_name": worksheet_name,
1125
+ "response": read_response,
1126
+ }
1127
+ except Exception as error: # verify is best-effort after a successful apply
1128
+ return {
1129
+ "success": False,
1130
+ "scope": "worksheet_sample",
1131
+ "endpoint": "/api/v1/excel/read_sheet",
1132
+ "worksheet_name": worksheet_name,
1133
+ "error": str(error),
1134
+ }
1135
+
1136
+
1137
+ def _pg_beautify_verify(client: MaybeAIClient, *, target, gid: int) -> dict[str, object]:
1138
+ try:
1139
+ metadata_response = client.post_json("/api/v1/excel/table/metadata", _pg_table_metadata_payload(target, gid))
1140
+ return {
1141
+ "success": bool(metadata_response.get("success", True)),
1142
+ "scope": "field_metadata",
1143
+ "endpoint": "/api/v1/excel/table/metadata",
1144
+ "gid": gid,
1145
+ "response": metadata_response,
1146
+ }
1147
+ except Exception as error:
1148
+ return {
1149
+ "success": False,
1150
+ "scope": "field_metadata",
1151
+ "endpoint": "/api/v1/excel/table/metadata",
1152
+ "gid": gid,
1153
+ "error": str(error),
1154
+ }
1155
+
1156
+
945
1157
  def _pg_fields_from_metadata(metadata_response: dict[str, object], *, gid: int, worksheet_name: str | None) -> list[dict[str, object]]:
946
1158
  tables = metadata_response.get("tables")
947
1159
  if not isinstance(tables, list):
@@ -1021,11 +1233,17 @@ def _pg_beautify_field_update(
1021
1233
  style = classification.style
1022
1234
  existing_property = field.get("property")
1023
1235
  property_payload: dict[str, object] = dict(existing_property) if isinstance(existing_property, dict) else {}
1024
- beautify_property = _field_property_payload(
1025
- formatter=str(style["format_code"]) if "format_code" in style else None,
1026
- color=str(style["font_color"]) if "font_color" in style else None,
1027
- background_color=str(style["bg_color"]) if "bg_color" in style else None,
1028
- width=None,
1236
+ formatter = str(style["format_code"]) if "format_code" in style else None
1237
+ font_color = str(style["font_color"]) if "font_color" in style else None
1238
+ beautify_property = (
1239
+ _field_property_payload(
1240
+ formatter=formatter,
1241
+ color=font_color,
1242
+ background_color=None,
1243
+ width=None,
1244
+ )
1245
+ if formatter is not None or font_color is not None
1246
+ else {}
1029
1247
  )
1030
1248
  beautify_style = beautify_property.pop("style", None)
1031
1249
  property_payload.update(beautify_property)
@@ -1909,20 +2127,27 @@ def beautify(
1909
2127
  doc_id: Optional[str] = typer.Option(None, "--doc-id", "--docs-id", help="MaybeAI document ID."),
1910
2128
  url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
1911
2129
  uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
2130
+ gid: Optional[int] = typer.Option(None, "--gid", help="Worksheet gid."),
1912
2131
  worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Optional worksheet name to beautify."),
1913
2132
  mode: Optional[str] = typer.Option(None, "--mode", help="single_table, multi_table, or auto_detect."),
1914
2133
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview the generated beautify plan without applying styles."),
2134
+ verify: bool = typer.Option(False, "--verify", help="Read back a bounded sample after applying styles."),
2135
+ config: Optional[Path] = typer.Option(None, "--config", exists=True, dir_okay=False, help="Optional JSON beautify style policy."),
1915
2136
  output: Optional[str] = output_option(),
1916
2137
  verbose: Optional[bool] = verbose_option(),
1917
2138
  timeout: Optional[float] = timeout_option(),
1918
2139
  ) -> None:
1919
2140
  try:
2141
+ if dry_run and verify:
2142
+ raise UsageError("--dry-run and --verify cannot be combined.")
2143
+ config_payload, config_source = _beautify_config(config)
2144
+ style_policy = _beautify_style_policy(config_payload)
1920
2145
  state = common_options(
1921
2146
  ctx,
1922
2147
  doc_id=doc_id,
1923
2148
  url=url,
1924
2149
  uri=uri,
1925
- gid=None,
2150
+ gid=gid,
1926
2151
  worksheet_name=worksheet_name,
1927
2152
  output=output,
1928
2153
  verbose=verbose,
@@ -1939,28 +2164,11 @@ def beautify(
1939
2164
  },
1940
2165
  )
1941
2166
  _worksheet_style_mode(mode)
1942
- selected_worksheets: list[dict[str, object]] = []
1943
- for worksheet in _worksheet_items(worksheet_response):
1944
- worksheet_item_name = _worksheet_name(worksheet)
1945
- if not worksheet_item_name:
1946
- continue
1947
- if worksheet_name and worksheet_item_name != worksheet_name:
1948
- continue
1949
- row_count = _int_payload_value(worksheet, ("row_count", "rows", "total_rows", "max_row"))
1950
- column_count = _int_payload_value(
1951
- worksheet,
1952
- ("column_count", "col_count", "columns", "total_columns", "max_column", "max_col"),
1953
- )
1954
- if (row_count is not None and row_count <= 0) or (column_count is not None and column_count <= 0):
1955
- continue
1956
- selected_worksheets.append(worksheet)
1957
- if not selected_worksheets:
1958
- if worksheet_name:
1959
- raise UsageError(f"No styleable worksheet found for --worksheet-name {worksheet_name!r}.")
1960
- raise UsageError("No styleable worksheets found. The workbook may be empty or missing dimensions.")
2167
+ selected_worksheets = _select_beautify_worksheets(target, worksheet_response)
1961
2168
 
1962
2169
  plans: list[dict[str, object]] = []
1963
2170
  responses: list[dict[str, object]] = []
2171
+ verify_results: list[dict[str, object]] = []
1964
2172
  if dry_run:
1965
2173
  for worksheet in selected_worksheets:
1966
2174
  engine = _normalize_engine(worksheet.get("data_engine") or worksheet.get("engine"))
@@ -1993,6 +2201,7 @@ def beautify(
1993
2201
  "gid": gid,
1994
2202
  "metadata_endpoint": "/api/v1/excel/table/metadata",
1995
2203
  "operation": "field formatter/style updates with header layout defaults",
2204
+ "style_policy": style_policy,
1996
2205
  "layout": _pg_beautify_layout_plan(len(fields)),
1997
2206
  "columns": columns,
1998
2207
  "fields": columns,
@@ -2007,7 +2216,7 @@ def beautify(
2007
2216
  "value_render_option": "FORMATTED_VALUE",
2008
2217
  }
2009
2218
  read_response = client.post_json("/api/v1/excel/read_sheet", read_payload)
2010
- plans.append(_excel_beautify_plan(worksheet, read_response))
2219
+ plans.append(_excel_beautify_plan(worksheet, read_response, style_policy=style_policy))
2011
2220
  response: dict[str, object] = {
2012
2221
  "success": True,
2013
2222
  "dry_run": True,
@@ -2076,6 +2285,8 @@ def beautify(
2076
2285
  )
2077
2286
  if fallback_reason:
2078
2287
  responses[-1]["batch_update_fallback_reason"] = fallback_reason
2288
+ if verify:
2289
+ verify_results.append(_pg_beautify_verify(client, target=target, gid=gid))
2079
2290
  continue
2080
2291
 
2081
2292
  read_payload = base_payload(
@@ -2085,7 +2296,7 @@ def beautify(
2085
2296
  "value_render_option": "FORMATTED_VALUE",
2086
2297
  }
2087
2298
  read_response = client.post_json("/api/v1/excel/read_sheet", read_payload)
2088
- plan = _excel_beautify_plan(worksheet, read_response)
2299
+ plan = _excel_beautify_plan(worksheet, read_response, style_policy=style_policy)
2089
2300
  plans.append(plan)
2090
2301
  operation_responses: list[dict[str, object]] = []
2091
2302
  for operation in plan["operations"]:
@@ -2109,6 +2320,8 @@ def beautify(
2109
2320
  "operations": operation_responses,
2110
2321
  }
2111
2322
  )
2323
+ if verify:
2324
+ verify_results.append(_excel_beautify_verify(client, target=target, plan=plan))
2112
2325
  response = {
2113
2326
  "success": all(bool(item.get("success", True)) for item in responses),
2114
2327
  "styled_worksheet_count": len(responses),
@@ -2142,7 +2355,10 @@ def beautify(
2142
2355
  "list_endpoint": list_endpoint,
2143
2356
  "worksheet_count": len(plans),
2144
2357
  "engine": plans[0].get("engine") if len(plans) == 1 else "mixed",
2358
+ "config_applied": config_payload is not None,
2359
+ **({"config_source": config_source} if config_source else {}),
2145
2360
  },
2361
+ verify=verify_results[0] if len(verify_results) == 1 else (verify_results or None),
2146
2362
  metadata=get_endpoint_metadata("style.beautify") if state.verbose else None,
2147
2363
  ),
2148
2364
  state.output,