maybeai-sheet-cli 0.19.2__tar.gz → 0.19.3__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.19.3}/PKG-INFO +1 -1
  2. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/pyproject.toml +1 -1
  3. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/style.py +252 -36
  4. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/endpoints.py +2 -1
  5. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_endpoints.py +6 -0
  6. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_phase2_cli.py +277 -7
  7. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/.gitignore +0 -0
  8. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/README.md +0 -0
  9. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/__init__.py +0 -0
  10. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/chart_config.py +0 -0
  11. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/chart_style_summary.py +0 -0
  12. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/cli.py +0 -0
  13. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/client.py +0 -0
  14. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/__init__.py +0 -0
  15. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/_phase2.py +0 -0
  16. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/cell.py +0 -0
  17. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/chart.py +0 -0
  18. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/column.py +0 -0
  19. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/dashboard.py +0 -0
  20. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/db_table.py +0 -0
  21. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/excel_table.py +0 -0
  22. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/excel_worksheet.py +0 -0
  23. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/file.py +0 -0
  24. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/formula.py +0 -0
  25. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/history.py +0 -0
  26. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/image.py +0 -0
  27. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/media.py +0 -0
  28. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/pivot.py +0 -0
  29. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/range.py +0 -0
  30. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/raw.py +0 -0
  31. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/row.py +0 -0
  32. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/share.py +0 -0
  33. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/sheet.py +0 -0
  34. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/table.py +0 -0
  35. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/workbook.py +0 -0
  36. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/commands/worksheet.py +0 -0
  37. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/config.py +0 -0
  38. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/dashboard_interactions.py +0 -0
  39. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/dashboard_plan.py +0 -0
  40. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/errors.py +0 -0
  41. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/formatters.py +0 -0
  42. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/models/__init__.py +0 -0
  43. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/models/share.py +0 -0
  44. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/models/sheet.py +0 -0
  45. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/models/workbook.py +0 -0
  46. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/options.py +0 -0
  47. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/protocol.py +0 -0
  48. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/resolver.py +0 -0
  49. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/table_resolver.py +0 -0
  50. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/src/maybeai_sheet/update.py +0 -0
  51. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/build_fixtures.py +0 -0
  52. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
  53. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
  54. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
  55. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/rows.json +0 -0
  56. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
  57. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/fixtures/benchmarks/simple-values.json +0 -0
  58. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_benchmark_runner.py +0 -0
  59. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_cli.py +0 -0
  60. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_dashboard_plan.py +0 -0
  61. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_formatters.py +0 -0
  62. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_resolver.py +0 -0
  63. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_two_engine_cli.py +0 -0
  64. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/tests/test_update.py +0 -0
  65. {maybeai_sheet_cli-0.19.2 → maybeai_sheet_cli-0.19.3}/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.19.3
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
@@ -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.19.3"
8
8
  description = "CLI for common MaybeAI spreadsheet operations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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,
@@ -181,7 +181,8 @@ COMMAND_ENDPOINTS: dict[str, EndpointMetadata] = {
181
181
  "POST",
182
182
  "/api/v1/excel/list_worksheets + /api/v1/excel/read_sheet + /api/v1/excel/freeze_panes + /api/v1/excel/set_auto_filter + /api/v1/excel/batch_set_cell_style | /api/v1/excel/table/metadata + /api/v1/excel/db_table/field/batch_update",
183
183
  dry_run_supported=True,
184
- notes="CLI-composed agent-friendly worksheet beautifier. Excelize worksheets get header freeze, auto-filter, header styling, and semantic column styles. PG/SheetTable worksheets get batched field formatter/style metadata updates with single-field fallback.",
184
+ verify_supported=True,
185
+ notes="CLI-composed agent-friendly worksheet beautifier. Excelize worksheets get header freeze, auto-filter, header styling, zebra body policy, and semantic column format/alignment without default column backgrounds. PG/SheetTable worksheets get batched field formatter/style metadata updates with single-field fallback.",
185
186
  ),
186
187
  "style.columns-width": _meta("style.columns-width", "API", "POST", "/api/v1/excel/set_columns_width", dry_run_supported=True),
187
188
  "style.rows-height": _meta("style.rows-height", "API", "POST", "/api/v1/excel/set_rows_height", dry_run_supported=True),
@@ -140,6 +140,12 @@ class EndpointRegistryTests(unittest.TestCase):
140
140
  self.assertTrue(metadata.large_output)
141
141
  self.assertIsNotNone(metadata.default_limit)
142
142
 
143
+ def test_style_beautify_metadata_supports_optional_verify(self) -> None:
144
+ metadata = get_endpoint_metadata("style.beautify")
145
+
146
+ self.assertTrue(metadata.dry_run_supported)
147
+ self.assertTrue(metadata.verify_supported)
148
+
143
149
  def test_missing_phase_2_commands_are_recorded_but_not_implemented(self) -> None:
144
150
  for command_id in (
145
151
  "range.used-range",
@@ -25,6 +25,15 @@ class Phase2CLITests(unittest.TestCase):
25
25
  self.runner = CliRunner()
26
26
  self.env = {"MAYBEAI_API_TOKEN": "test-token"}
27
27
 
28
+ def _assert_no_background_keys(self, payload: object) -> None:
29
+ if isinstance(payload, dict):
30
+ for key, value in payload.items():
31
+ self.assertNotIn(key, {"bg_color", "backgroundColor", "fillColor"})
32
+ self._assert_no_background_keys(value)
33
+ elif isinstance(payload, list):
34
+ for item in payload:
35
+ self._assert_no_background_keys(item)
36
+
28
37
  def _write_two_chart_dashboard_spec(self, directory: str, *, worksheet_name: str = "Sales_Dashboard") -> Path:
29
38
  spec_path = Path(directory) / "dashboard.json"
30
39
  spec_path.write_text(
@@ -1455,6 +1464,12 @@ class Phase2CLITests(unittest.TestCase):
1455
1464
  {"success": True},
1456
1465
  {"success": True},
1457
1466
  {"success": True},
1467
+ {"success": True},
1468
+ {"success": True},
1469
+ {"success": True},
1470
+ {"success": True},
1471
+ {"success": True},
1472
+ {"success": True},
1458
1473
  ]
1459
1474
  result = self.runner.invoke(
1460
1475
  app,
@@ -1463,6 +1478,8 @@ class Phase2CLITests(unittest.TestCase):
1463
1478
  "beautify",
1464
1479
  "--docs-id",
1465
1480
  "abc123",
1481
+ "--worksheet-name",
1482
+ "Summary",
1466
1483
  ],
1467
1484
  env=self.env,
1468
1485
  )
@@ -1475,8 +1492,8 @@ class Phase2CLITests(unittest.TestCase):
1475
1492
  data = json.loads(result.output)
1476
1493
  self.assertEqual(data["endpoint"], "/api/v1/excel/beautify_worksheet")
1477
1494
  self.assertEqual(data["context"]["source_command"], "style.beautify")
1478
- self.assertEqual(data["context"]["worksheet_count"], 2)
1479
- self.assertEqual(data["result"]["styled_worksheet_count"], 2)
1495
+ self.assertEqual(data["context"]["worksheet_count"], 1)
1496
+ self.assertEqual(data["result"]["styled_worksheet_count"], 1)
1480
1497
 
1481
1498
  def test_style_beautify_dry_run_previews_classification_plan_without_applying_styles(self) -> None:
1482
1499
  worksheet_response = {
@@ -1514,6 +1531,255 @@ class Phase2CLITests(unittest.TestCase):
1514
1531
  self.assertGreaterEqual(worksheet_plan["columns"][0]["confidence"], 0.8)
1515
1532
  self.assertTrue(worksheet_plan["columns"][0]["reasons"])
1516
1533
 
1534
+ def test_style_beautify_default_policy_uses_zebra_rows_not_column_backgrounds(self) -> None:
1535
+ worksheet_response = {
1536
+ "success": True,
1537
+ "worksheets": [{"worksheet_name": "Report", "row_count": 4, "column_count": 4, "data_engine": "excelize"}],
1538
+ }
1539
+ read_response = {
1540
+ "success": True,
1541
+ "values": [
1542
+ ["Date", "Revenue", "Margin %", "Status"],
1543
+ ["2026-01-01", "$100.00", "12.50%", "Active"],
1544
+ ["2026-01-02", "$120.00", "9.80%", "Closed"],
1545
+ ],
1546
+ "range_address": "A1:D3",
1547
+ }
1548
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1549
+ post_json.side_effect = [worksheet_response, read_response]
1550
+ result = self.runner.invoke(
1551
+ app,
1552
+ [
1553
+ "style",
1554
+ "beautify",
1555
+ "--doc-id",
1556
+ "abc123",
1557
+ "--dry-run",
1558
+ "--output",
1559
+ "json",
1560
+ ],
1561
+ env=self.env,
1562
+ )
1563
+
1564
+ self.assertEqual(result.exit_code, 0, result.output)
1565
+ data = json.loads(result.output)
1566
+ worksheet_plan = data["result"]["plan"]["worksheets"][0]
1567
+ style_policy = worksheet_plan["style_policy"]
1568
+ self.assertEqual(style_policy["defaults"]["preset"], "clean_data_table")
1569
+ self.assertTrue(style_policy["bodyBanding"]["enabled"])
1570
+ self.assertEqual(style_policy["bodyBanding"]["oddRowBackground"], "#FFFFFF")
1571
+ self.assertEqual(style_policy["bodyBanding"]["evenRowBackground"], "#F8FAFC")
1572
+ self.assertFalse(style_policy["columnPolicy"]["backgroundBySemanticType"])
1573
+ column_operations = [
1574
+ operation
1575
+ for operation in worksheet_plan["operations"]
1576
+ if operation.get("kind") == "semantic_column_style"
1577
+ ]
1578
+ self.assertTrue(column_operations)
1579
+ self._assert_no_background_keys(column_operations)
1580
+ banding_operations = [
1581
+ operation
1582
+ for operation in worksheet_plan["operations"]
1583
+ if operation.get("kind") == "body_banding"
1584
+ ]
1585
+ self.assertEqual(len(banding_operations), 2)
1586
+ banding_styles = [operation["payload"]["style"]["bg_color"] for operation in banding_operations]
1587
+ self.assertEqual(banding_styles, ["#FFFFFF", "#F8FAFC"])
1588
+
1589
+ def test_style_beautify_url_gid_selects_only_matching_worksheet(self) -> None:
1590
+ worksheet_metadata = {
1591
+ "success": True,
1592
+ "worksheets": [
1593
+ {"gid": 0, "worksheet_name": "Summary", "row_count": 12, "column_count": 2, "data_engine": "excelize"},
1594
+ {"gid": 7, "worksheet_name": "Details", "row_count": 8, "column_count": 2, "data_engine": "excelize"},
1595
+ ],
1596
+ }
1597
+ details_read = {"success": True, "values": [["SKU", "Cost"], ["A1", 20]], "range_address": "A1:B2"}
1598
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1599
+ post_json.side_effect = [
1600
+ worksheet_metadata,
1601
+ details_read,
1602
+ {"success": True},
1603
+ {"success": True},
1604
+ {"success": True},
1605
+ {"success": True},
1606
+ {"success": True},
1607
+ {"success": True},
1608
+ {"success": True},
1609
+ ]
1610
+ result = self.runner.invoke(
1611
+ app,
1612
+ [
1613
+ "style",
1614
+ "beautify",
1615
+ "--url",
1616
+ "https://www.maybe.ai/docs/spreadsheets/d/abc123?gid=7",
1617
+ "--output",
1618
+ "json",
1619
+ ],
1620
+ env=self.env,
1621
+ )
1622
+
1623
+ self.assertEqual(result.exit_code, 0, result.output)
1624
+ read_payload = post_json.call_args_list[1].args[1]
1625
+ self.assertEqual(read_payload["worksheet_name"], "Details")
1626
+ data = json.loads(result.output)
1627
+ self.assertEqual(data["context"]["worksheet_count"], 1)
1628
+ self.assertEqual(data["result"]["styled_worksheet_count"], 1)
1629
+ self.assertEqual(data["result"]["worksheet_name"], "Details")
1630
+
1631
+ def test_style_beautify_url_without_gid_rejects_multiple_worksheets(self) -> None:
1632
+ worksheet_metadata = {
1633
+ "success": True,
1634
+ "worksheets": [
1635
+ {"gid": 0, "worksheet_name": "Summary", "row_count": 12, "column_count": 2, "data_engine": "excelize"},
1636
+ {"gid": 7, "worksheet_name": "Details", "row_count": 8, "column_count": 2, "data_engine": "excelize"},
1637
+ ],
1638
+ }
1639
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1640
+ post_json.side_effect = [worksheet_metadata]
1641
+ result = self.runner.invoke(
1642
+ app,
1643
+ [
1644
+ "style",
1645
+ "beautify",
1646
+ "--url",
1647
+ "https://www.maybe.ai/docs/spreadsheets/d/abc123",
1648
+ "--output",
1649
+ "json",
1650
+ ],
1651
+ env=self.env,
1652
+ )
1653
+
1654
+ self.assertNotEqual(result.exit_code, 0, result.output)
1655
+ self.assertIn("--gid", result.output)
1656
+ self.assertIn("--worksheet-name", result.output)
1657
+ self.assertEqual(len(post_json.call_args_list), 1)
1658
+
1659
+ def test_style_beautify_rejects_dry_run_verify_before_backend_calls(self) -> None:
1660
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1661
+ result = self.runner.invoke(
1662
+ app,
1663
+ [
1664
+ "style",
1665
+ "beautify",
1666
+ "--doc-id",
1667
+ "abc123",
1668
+ "--dry-run",
1669
+ "--verify",
1670
+ "--output",
1671
+ "json",
1672
+ ],
1673
+ env=self.env,
1674
+ )
1675
+
1676
+ self.assertNotEqual(result.exit_code, 0, result.output)
1677
+ self.assertIn("--dry-run", result.output)
1678
+ self.assertIn("--verify", result.output)
1679
+ post_json.assert_not_called()
1680
+
1681
+ def test_style_beautify_config_updates_policy_and_context(self) -> None:
1682
+ worksheet_response = {
1683
+ "success": True,
1684
+ "worksheets": [{"worksheet_name": "Report", "row_count": 4, "column_count": 2, "data_engine": "excelize"}],
1685
+ }
1686
+ read_response = {
1687
+ "success": True,
1688
+ "values": [["Name", "Revenue"], ["A", "$100.00"]],
1689
+ "range_address": "A1:B2",
1690
+ }
1691
+ with tempfile.TemporaryDirectory() as directory:
1692
+ config_path = Path(directory) / "beautify-config.json"
1693
+ config_path.write_text(
1694
+ json.dumps(
1695
+ {
1696
+ "bodyBanding": {
1697
+ "enabled": True,
1698
+ "oddRowBackground": "#FFFFFF",
1699
+ "evenRowBackground": "#EEF2FF",
1700
+ },
1701
+ "columnPolicy": {
1702
+ "backgroundBySemanticType": False,
1703
+ "allowFontColorBySemanticType": True,
1704
+ "allowFormatBySemanticType": True,
1705
+ "allowAlignmentBySemanticType": True,
1706
+ },
1707
+ }
1708
+ )
1709
+ )
1710
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1711
+ post_json.side_effect = [worksheet_response, read_response]
1712
+ result = self.runner.invoke(
1713
+ app,
1714
+ [
1715
+ "style",
1716
+ "beautify",
1717
+ "--doc-id",
1718
+ "abc123",
1719
+ "--dry-run",
1720
+ "--config",
1721
+ str(config_path),
1722
+ "--output",
1723
+ "json",
1724
+ ],
1725
+ env=self.env,
1726
+ )
1727
+
1728
+ self.assertEqual(result.exit_code, 0, result.output)
1729
+ data = json.loads(result.output)
1730
+ self.assertTrue(data["context"]["config_applied"])
1731
+ self.assertTrue(data["context"]["config_source"].endswith("beautify-config.json"))
1732
+ style_policy = data["result"]["plan"]["worksheets"][0]["style_policy"]
1733
+ self.assertEqual(style_policy["bodyBanding"]["evenRowBackground"], "#EEF2FF")
1734
+ self.assertFalse(style_policy["columnPolicy"]["backgroundBySemanticType"])
1735
+
1736
+ def test_style_beautify_verify_reads_back_after_apply(self) -> None:
1737
+ worksheet_metadata = {
1738
+ "success": True,
1739
+ "worksheets": [{"gid": 0, "worksheet_name": "Report", "row_count": 4, "column_count": 2, "data_engine": "excelize"}],
1740
+ }
1741
+ read_response = {
1742
+ "success": True,
1743
+ "values": [["Name", "Revenue"], ["A", "$100.00"]],
1744
+ "range_address": "A1:B2",
1745
+ }
1746
+ verify_response = {"success": True, "values": [["Name", "Revenue"], ["A", "$100.00"]], "range_address": "A1:B4"}
1747
+ with patch("maybeai_sheet.commands.style.MaybeAIClient.post_json") as post_json:
1748
+ post_json.side_effect = [
1749
+ worksheet_metadata,
1750
+ read_response,
1751
+ {"success": True},
1752
+ {"success": True},
1753
+ {"success": True},
1754
+ {"success": True},
1755
+ {"success": True},
1756
+ {"success": True},
1757
+ {"success": True},
1758
+ verify_response,
1759
+ ]
1760
+ result = self.runner.invoke(
1761
+ app,
1762
+ [
1763
+ "style",
1764
+ "beautify",
1765
+ "--url",
1766
+ "https://www.maybe.ai/docs/spreadsheets/d/abc123?gid=0",
1767
+ "--verify",
1768
+ "--output",
1769
+ "json",
1770
+ ],
1771
+ env=self.env,
1772
+ )
1773
+
1774
+ self.assertEqual(result.exit_code, 0, result.output)
1775
+ self.assertEqual(post_json.call_args_list[-1].args[0], "/api/v1/excel/read_sheet")
1776
+ verify_payload = post_json.call_args_list[-1].args[1]
1777
+ self.assertEqual(verify_payload["worksheet_name"], "Report")
1778
+ self.assertEqual(verify_payload["range_address"], "A1:B4")
1779
+ data = json.loads(result.output)
1780
+ self.assertTrue(data["verify"]["success"])
1781
+ self.assertEqual(data["verify"]["scope"], "worksheet_sample")
1782
+
1517
1783
  def test_style_beautify_excel_worksheet_applies_header_filter_and_semantic_column_styles(self) -> None:
1518
1784
  worksheet_metadata = {
1519
1785
  "success": True,
@@ -1536,6 +1802,8 @@ class Phase2CLITests(unittest.TestCase):
1536
1802
  {"success": True, "operation": "freeze"},
1537
1803
  {"success": True, "operation": "filter"},
1538
1804
  {"success": True, "operation": "header_style"},
1805
+ {"success": True, "operation": "odd_row_banding"},
1806
+ {"success": True, "operation": "even_row_banding"},
1539
1807
  {"success": True, "operation": "date_style"},
1540
1808
  {"success": True, "operation": "currency_style"},
1541
1809
  {"success": True, "operation": "percent_style"},
@@ -1616,6 +1884,8 @@ class Phase2CLITests(unittest.TestCase):
1616
1884
  {"success": True, "operation": "freeze"},
1617
1885
  {"success": True, "operation": "filter"},
1618
1886
  {"success": True, "operation": "header_style"},
1887
+ {"success": True, "operation": "odd_row_banding"},
1888
+ {"success": True, "operation": "even_row_banding"},
1619
1889
  {"success": True, "operation": "currency_style"},
1620
1890
  {"success": True, "operation": "percent_style"},
1621
1891
  {"success": True, "operation": "integer_style"},
@@ -1642,7 +1912,7 @@ class Phase2CLITests(unittest.TestCase):
1642
1912
  category_by_range = {
1643
1913
  payload["range_addresses"][0]: payload["style"]["format"]
1644
1914
  for payload in style_payloads
1645
- if payload["range_addresses"][0] != "A1:E1"
1915
+ if payload["range_addresses"][0] != "A1:E1" and "format" in payload["style"]
1646
1916
  }
1647
1917
  self.assertEqual(category_by_range["A2:A3"], "currency")
1648
1918
  self.assertEqual(category_by_range["B2:B3"], "percent")
@@ -1808,12 +2078,12 @@ class Phase2CLITests(unittest.TestCase):
1808
2078
  margin_payload = next(payload for payload in update_payloads if payload["name"] == "margin_rate")
1809
2079
  self.assertNotIn("formatter", order_id_payload["property"])
1810
2080
  self.assertEqual(revenue_payload["property"]["formatter"], "$#,##0.00")
1811
- self.assertEqual(revenue_payload["property"]["style"]["backgroundColor"], "#ECFDF5")
2081
+ self.assertNotIn("backgroundColor", revenue_payload["property"]["style"])
1812
2082
  self.assertEqual(revenue_payload["property"]["customWidget"], {"type": "sparkline"})
1813
2083
  self.assertEqual(revenue_payload["property"]["style"]["borderColor"], "#111111")
1814
2084
  self.assertEqual(revenue_payload["property"]["style"]["width"], 188)
1815
2085
  self.assertEqual(margin_payload["property"]["formatter"], "0.00%")
1816
- self.assertEqual(margin_payload["property"]["style"]["backgroundColor"], "#EFF6FF")
2086
+ self.assertNotIn("backgroundColor", margin_payload["property"]["style"])
1817
2087
 
1818
2088
  data = json.loads(result.output)
1819
2089
  self.assertEqual(data["endpoint"], "/api/v1/excel/db_table/field/batch_update")
@@ -1893,7 +2163,7 @@ class Phase2CLITests(unittest.TestCase):
1893
2163
  self.assertEqual(batch_payload["updates"][0]["name"], "event_time")
1894
2164
  self.assertEqual(batch_payload["updates"][0]["property"]["formatter"], "yyyy-mm-dd")
1895
2165
  self.assertEqual(batch_payload["updates"][1]["property"]["formatter"], "$#,##0.00")
1896
- self.assertEqual(batch_payload["updates"][2]["property"]["style"]["backgroundColor"], "#FEF3C7")
2166
+ self.assertNotIn("backgroundColor", batch_payload["updates"][2]["property"]["style"])
1897
2167
 
1898
2168
  data = json.loads(result.output)
1899
2169
  self.assertEqual(data["endpoint"], "/api/v1/excel/db_table/field/batch_update")
@@ -2154,7 +2424,7 @@ class Phase2CLITests(unittest.TestCase):
2154
2424
  self.assertEqual(result.exit_code, 0, result.output)
2155
2425
  update_payload = post_json.call_args_list[2].args[1]["updates"][0]
2156
2426
  self.assertNotIn("formatter", update_payload["property"])
2157
- self.assertEqual(update_payload["property"]["style"]["backgroundColor"], "#FEF3C7")
2427
+ self.assertNotIn("backgroundColor", update_payload["property"]["style"])
2158
2428
 
2159
2429
  def test_style_beautify_pg_rejects_unmatched_table_metadata(self) -> None:
2160
2430
  worksheets = {