onetool-mcp 1.0.0b1__py3-none-any.whl
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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot_tools/excel.py
ADDED
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
"""Excel file manipulation tools.
|
|
2
|
+
|
|
3
|
+
Create, read, write Excel workbooks using openpyxl.
|
|
4
|
+
|
|
5
|
+
Based on excel-mcp-server by Haris Musa (MIT License).
|
|
6
|
+
https://github.com/haris-musa/excel-mcp-server
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Pack for dot notation: excel.create(), excel.read(), etc.
|
|
12
|
+
pack = "excel"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"add_sheet",
|
|
16
|
+
"cell_range",
|
|
17
|
+
"cell_shift",
|
|
18
|
+
"copy_range",
|
|
19
|
+
"create",
|
|
20
|
+
"create_table",
|
|
21
|
+
"delete_cols",
|
|
22
|
+
"delete_rows",
|
|
23
|
+
"formula",
|
|
24
|
+
"formulas",
|
|
25
|
+
"hyperlinks",
|
|
26
|
+
"info",
|
|
27
|
+
"insert_cols",
|
|
28
|
+
"insert_rows",
|
|
29
|
+
"merged_cells",
|
|
30
|
+
"named_ranges",
|
|
31
|
+
"read",
|
|
32
|
+
"search",
|
|
33
|
+
"sheets",
|
|
34
|
+
"table_data",
|
|
35
|
+
"table_info",
|
|
36
|
+
"tables",
|
|
37
|
+
"used_range",
|
|
38
|
+
"write",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Dependency declarations for CLI validation
|
|
42
|
+
__ot_requires__ = {
|
|
43
|
+
"lib": [("openpyxl", "pip install openpyxl")],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
import fnmatch
|
|
47
|
+
import re
|
|
48
|
+
from typing import TYPE_CHECKING, Any
|
|
49
|
+
|
|
50
|
+
from openpyxl import Workbook, load_workbook
|
|
51
|
+
from openpyxl.utils import get_column_letter
|
|
52
|
+
from openpyxl.utils.cell import column_index_from_string, coordinate_from_string
|
|
53
|
+
from openpyxl.worksheet.cell_range import CellRange
|
|
54
|
+
from openpyxl.worksheet.table import Table, TableStyleInfo
|
|
55
|
+
from pydantic import BaseModel
|
|
56
|
+
|
|
57
|
+
from ot.config import get_tool_config
|
|
58
|
+
from ot.logging import LogSpan
|
|
59
|
+
from ot.paths import resolve_cwd_path
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Config(BaseModel):
|
|
66
|
+
"""Pack configuration - discovered by registry."""
|
|
67
|
+
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_config() -> Config:
|
|
72
|
+
"""Get the tool configuration."""
|
|
73
|
+
return get_tool_config("excel", Config)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _expand_path(filepath: str) -> Path:
|
|
77
|
+
"""Resolve a file path relative to project directory.
|
|
78
|
+
|
|
79
|
+
Uses SDK resolve_cwd_path() for consistent path resolution.
|
|
80
|
+
|
|
81
|
+
Path resolution follows project conventions:
|
|
82
|
+
- Relative paths: resolved relative to project directory (OT_CWD)
|
|
83
|
+
- Absolute paths: used as-is
|
|
84
|
+
- ~ paths: expanded to home directory
|
|
85
|
+
- Prefixed paths (CWD/, GLOBAL/, OT_DIR/): resolved to respective dirs
|
|
86
|
+
|
|
87
|
+
Note: ${VAR} patterns are NOT expanded. Use ~/path instead of ${HOME}/path.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
filepath: Path string (can contain ~ or prefixes)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Resolved absolute Path
|
|
94
|
+
"""
|
|
95
|
+
return resolve_cwd_path(filepath)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_parent_dir(filepath: str) -> None:
|
|
99
|
+
"""Create parent directories if they don't exist."""
|
|
100
|
+
parent = _expand_path(filepath).parent
|
|
101
|
+
if parent and not parent.exists():
|
|
102
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_sheet(wb: Workbook, sheet_name: str | None) -> tuple[Any, str | None]:
|
|
106
|
+
"""Get worksheet by name or return active sheet.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Tuple of (worksheet, error_message). On success, error is None.
|
|
110
|
+
On failure, worksheet is None and error contains the message.
|
|
111
|
+
"""
|
|
112
|
+
if sheet_name:
|
|
113
|
+
if sheet_name not in wb.sheetnames:
|
|
114
|
+
return (
|
|
115
|
+
None,
|
|
116
|
+
f"Error: Sheet '{sheet_name}' not found. Available: {', '.join(wb.sheetnames)}",
|
|
117
|
+
)
|
|
118
|
+
return wb[sheet_name], None
|
|
119
|
+
return wb.active, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _col_to_index(col: int | str) -> int:
|
|
123
|
+
"""Convert column letter or number to 1-based index."""
|
|
124
|
+
if isinstance(col, int):
|
|
125
|
+
return col
|
|
126
|
+
return column_index_from_string(col)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _plural(count: int, singular: str, plural: str | None = None) -> str:
|
|
130
|
+
"""Return singular or plural form based on count."""
|
|
131
|
+
if count == 1:
|
|
132
|
+
return singular
|
|
133
|
+
return plural or f"{singular}s"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def create(*, filepath: str, sheet_name: str = "Sheet1") -> str:
|
|
137
|
+
"""Create new Excel workbook.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
filepath: Path to create the Excel file
|
|
141
|
+
sheet_name: Name for the initial sheet (default: "Sheet1")
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Success message with filepath
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
excel.create(filepath="output/report.xlsx")
|
|
148
|
+
excel.create(filepath="data.xlsx", sheet_name="Sales")
|
|
149
|
+
"""
|
|
150
|
+
with LogSpan(span="excel.create", filepath=filepath, sheet=sheet_name) as s:
|
|
151
|
+
try:
|
|
152
|
+
_ensure_parent_dir(filepath)
|
|
153
|
+
wb = Workbook()
|
|
154
|
+
ws = wb.active
|
|
155
|
+
ws.title = sheet_name
|
|
156
|
+
wb.save(_expand_path(filepath))
|
|
157
|
+
s.add(created=True)
|
|
158
|
+
return f"Created workbook: {filepath}"
|
|
159
|
+
except Exception as e:
|
|
160
|
+
s.add(error=str(e))
|
|
161
|
+
return f"Error: {e}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def add_sheet(*, filepath: str, sheet_name: str) -> str:
|
|
165
|
+
"""Add worksheet to existing workbook.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
filepath: Path to Excel file
|
|
169
|
+
sheet_name: Name for the new sheet
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Success message or error if sheet exists
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
excel.add_sheet(filepath="report.xlsx", sheet_name="Summary")
|
|
176
|
+
"""
|
|
177
|
+
with LogSpan(span="excel.add_sheet", filepath=filepath, sheet=sheet_name) as s:
|
|
178
|
+
try:
|
|
179
|
+
if not _expand_path(filepath).exists():
|
|
180
|
+
s.add(error="file_not_found")
|
|
181
|
+
return f"Error: File not found: {filepath}"
|
|
182
|
+
|
|
183
|
+
wb = load_workbook(_expand_path(filepath))
|
|
184
|
+
if sheet_name in wb.sheetnames:
|
|
185
|
+
s.add(error="sheet_exists")
|
|
186
|
+
return f"Error: Sheet '{sheet_name}' already exists"
|
|
187
|
+
|
|
188
|
+
wb.create_sheet(title=sheet_name)
|
|
189
|
+
wb.save(_expand_path(filepath))
|
|
190
|
+
s.add(created=True)
|
|
191
|
+
return f"Created sheet: {sheet_name}"
|
|
192
|
+
except Exception as e:
|
|
193
|
+
s.add(error=str(e))
|
|
194
|
+
return f"Error: {e}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read(
|
|
198
|
+
*,
|
|
199
|
+
filepath: str,
|
|
200
|
+
sheet_name: str | None = None,
|
|
201
|
+
start_cell: str = "A1",
|
|
202
|
+
end_cell: str | None = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Read data from Excel worksheet.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
filepath: Path to Excel file
|
|
208
|
+
sheet_name: Sheet to read (default: active sheet)
|
|
209
|
+
start_cell: Starting cell reference (default: "A1")
|
|
210
|
+
end_cell: Ending cell reference (default: auto-detect)
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Data as JSON list of lists, or error message
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
excel.read(filepath="data.xlsx")
|
|
217
|
+
excel.read(filepath="data.xlsx", sheet_name="Sales", start_cell="B2", end_cell="D10")
|
|
218
|
+
"""
|
|
219
|
+
with LogSpan(span="excel.read", filepath=filepath, sheet=sheet_name) as s:
|
|
220
|
+
try:
|
|
221
|
+
if not _expand_path(filepath).exists():
|
|
222
|
+
s.add(error="file_not_found")
|
|
223
|
+
return f"Error: File not found: {filepath}"
|
|
224
|
+
|
|
225
|
+
wb = load_workbook(_expand_path(filepath), data_only=True)
|
|
226
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
227
|
+
if err:
|
|
228
|
+
wb.close()
|
|
229
|
+
s.add(error="sheet_not_found")
|
|
230
|
+
return err
|
|
231
|
+
|
|
232
|
+
# Determine range
|
|
233
|
+
if end_cell:
|
|
234
|
+
cell_range = f"{start_cell}:{end_cell}"
|
|
235
|
+
else:
|
|
236
|
+
# Auto-detect used range
|
|
237
|
+
if ws.max_row and ws.max_column:
|
|
238
|
+
end_col = get_column_letter(ws.max_column)
|
|
239
|
+
cell_range = f"{start_cell}:{end_col}{ws.max_row}"
|
|
240
|
+
else:
|
|
241
|
+
s.add(rows=0)
|
|
242
|
+
return "No data in worksheet"
|
|
243
|
+
|
|
244
|
+
# Read data
|
|
245
|
+
rows = []
|
|
246
|
+
for row in ws[cell_range]:
|
|
247
|
+
row_data = []
|
|
248
|
+
for cell in row:
|
|
249
|
+
value = cell.value
|
|
250
|
+
if value is None:
|
|
251
|
+
row_data.append("")
|
|
252
|
+
else:
|
|
253
|
+
row_data.append(value)
|
|
254
|
+
rows.append(row_data)
|
|
255
|
+
|
|
256
|
+
s.add(rows=len(rows))
|
|
257
|
+
return rows
|
|
258
|
+
except Exception as e:
|
|
259
|
+
s.add(error=str(e))
|
|
260
|
+
return f"Error: {e}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def write(
|
|
264
|
+
*,
|
|
265
|
+
filepath: str,
|
|
266
|
+
data: list[list[Any]],
|
|
267
|
+
sheet_name: str | None = None,
|
|
268
|
+
start_cell: str = "A1",
|
|
269
|
+
create_if_missing: bool = False,
|
|
270
|
+
) -> str:
|
|
271
|
+
"""Write data to Excel worksheet.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
filepath: Path to Excel file
|
|
275
|
+
data: List of rows, where each row is a list of values
|
|
276
|
+
sheet_name: Sheet to write to (default: active sheet)
|
|
277
|
+
start_cell: Starting cell reference (default: "A1")
|
|
278
|
+
create_if_missing: Create file if it doesn't exist (default: False)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Success message with row count
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
excel.write(filepath="report.xlsx", data=[["Name", "Score"], ["Alice", 95]])
|
|
285
|
+
excel.write(filepath="report.xlsx", data=[[1, 2, 3]], sheet_name="Numbers", start_cell="B5")
|
|
286
|
+
excel.write(filepath="new.xlsx", data=[["Test"]], create_if_missing=True)
|
|
287
|
+
"""
|
|
288
|
+
with LogSpan(span="excel.write", filepath=filepath, sheet=sheet_name, rows=len(data)) as s:
|
|
289
|
+
try:
|
|
290
|
+
path = _expand_path(filepath)
|
|
291
|
+
if not path.exists():
|
|
292
|
+
if not create_if_missing:
|
|
293
|
+
s.add(error="file_not_found")
|
|
294
|
+
return f"Error: File not found: {filepath}"
|
|
295
|
+
_ensure_parent_dir(filepath)
|
|
296
|
+
wb = Workbook()
|
|
297
|
+
if sheet_name:
|
|
298
|
+
wb.active.title = sheet_name
|
|
299
|
+
else:
|
|
300
|
+
wb = load_workbook(path)
|
|
301
|
+
|
|
302
|
+
if not data:
|
|
303
|
+
s.add(error="no_data")
|
|
304
|
+
return "Error: No data provided"
|
|
305
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
306
|
+
if err:
|
|
307
|
+
wb.close()
|
|
308
|
+
s.add(error="sheet_not_found")
|
|
309
|
+
return err
|
|
310
|
+
|
|
311
|
+
# Parse start cell
|
|
312
|
+
col_letter, start_row = coordinate_from_string(start_cell)
|
|
313
|
+
start_col = column_index_from_string(col_letter)
|
|
314
|
+
|
|
315
|
+
# Write data
|
|
316
|
+
for row_idx, row_data in enumerate(data):
|
|
317
|
+
for col_idx, value in enumerate(row_data):
|
|
318
|
+
ws.cell(
|
|
319
|
+
row=start_row + row_idx,
|
|
320
|
+
column=start_col + col_idx,
|
|
321
|
+
value=value,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
wb.save(_expand_path(filepath))
|
|
325
|
+
sheet_used = sheet_name or ws.title
|
|
326
|
+
s.add(written=True)
|
|
327
|
+
return f"Wrote {len(data)} {_plural(len(data), 'row')} to {sheet_used}"
|
|
328
|
+
except Exception as e:
|
|
329
|
+
s.add(error=str(e))
|
|
330
|
+
return f"Error: {e}"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def info(*, filepath: str, include_ranges: bool = False) -> str:
|
|
334
|
+
"""Get workbook metadata.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
filepath: Path to Excel file
|
|
338
|
+
include_ranges: Include used range for each sheet (default: False)
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Formatted info with filename, sheets, size, and optionally ranges
|
|
342
|
+
|
|
343
|
+
Example:
|
|
344
|
+
excel.info(filepath="report.xlsx")
|
|
345
|
+
excel.info(filepath="data.xlsx", include_ranges=True)
|
|
346
|
+
"""
|
|
347
|
+
with LogSpan(span="excel.info", filepath=filepath) as s:
|
|
348
|
+
try:
|
|
349
|
+
resolved = _expand_path(filepath)
|
|
350
|
+
if not resolved.exists():
|
|
351
|
+
s.add(error="file_not_found")
|
|
352
|
+
return f"Error: File not found: {filepath}"
|
|
353
|
+
|
|
354
|
+
# Use read_only=True only when we don't need ranges
|
|
355
|
+
# (read_only mode doesn't populate max_row/max_column accurately)
|
|
356
|
+
wb = load_workbook(resolved, read_only=not include_ranges)
|
|
357
|
+
file_size = resolved.stat().st_size
|
|
358
|
+
|
|
359
|
+
info_dict: dict[str, Any] = {
|
|
360
|
+
"file": resolved.name,
|
|
361
|
+
"sheets": wb.sheetnames,
|
|
362
|
+
"size": f"{file_size:,} bytes",
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if include_ranges:
|
|
366
|
+
ranges = {}
|
|
367
|
+
for sheet_name in wb.sheetnames:
|
|
368
|
+
ws = wb[sheet_name]
|
|
369
|
+
if ws.max_row and ws.max_column:
|
|
370
|
+
end_col = get_column_letter(ws.max_column)
|
|
371
|
+
ranges[sheet_name] = f"A1:{end_col}{ws.max_row}"
|
|
372
|
+
else:
|
|
373
|
+
ranges[sheet_name] = "empty"
|
|
374
|
+
info_dict["ranges"] = ranges
|
|
375
|
+
|
|
376
|
+
wb.close()
|
|
377
|
+
s.add(sheets=len(info_dict["sheets"]))
|
|
378
|
+
return info_dict
|
|
379
|
+
except Exception as e:
|
|
380
|
+
s.add(error=str(e))
|
|
381
|
+
return f"Error: {e}"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def formula(
|
|
385
|
+
*,
|
|
386
|
+
filepath: str,
|
|
387
|
+
cell: str,
|
|
388
|
+
formula: str,
|
|
389
|
+
sheet_name: str | None = None,
|
|
390
|
+
) -> str:
|
|
391
|
+
"""Apply Excel formula to a cell.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
filepath: Path to Excel file
|
|
395
|
+
cell: Cell reference (e.g., "A1", "B10")
|
|
396
|
+
formula: Excel formula (= prefix added automatically if missing)
|
|
397
|
+
sheet_name: Sheet name (default: active sheet)
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Success message with applied formula
|
|
401
|
+
|
|
402
|
+
Example:
|
|
403
|
+
excel.formula(filepath="sales.xlsx", cell="C10", formula="=SUM(C2:C9)")
|
|
404
|
+
excel.formula(filepath="data.xlsx", cell="A1", formula="=TODAY()", sheet_name="Summary")
|
|
405
|
+
"""
|
|
406
|
+
with LogSpan(span="excel.formula", filepath=filepath, cell=cell) as s:
|
|
407
|
+
try:
|
|
408
|
+
if not _expand_path(filepath).exists():
|
|
409
|
+
s.add(error="file_not_found")
|
|
410
|
+
return f"Error: File not found: {filepath}"
|
|
411
|
+
|
|
412
|
+
wb = load_workbook(_expand_path(filepath))
|
|
413
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
414
|
+
if err:
|
|
415
|
+
wb.close()
|
|
416
|
+
s.add(error="sheet_not_found")
|
|
417
|
+
return err
|
|
418
|
+
|
|
419
|
+
# Auto-prepend = if missing
|
|
420
|
+
formula_str = formula if formula.startswith("=") else f"={formula}"
|
|
421
|
+
|
|
422
|
+
ws[cell] = formula_str
|
|
423
|
+
wb.save(_expand_path(filepath))
|
|
424
|
+
s.add(applied=True)
|
|
425
|
+
return f"Applied formula to {cell}: {formula_str}"
|
|
426
|
+
except Exception as e:
|
|
427
|
+
s.add(error=str(e))
|
|
428
|
+
return f"Error: {e}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# =============================================================================
|
|
432
|
+
# Tier 1: Range Manipulation (Pure Functions)
|
|
433
|
+
# =============================================================================
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def cell_range(
|
|
437
|
+
*,
|
|
438
|
+
cell: str,
|
|
439
|
+
right: int = 0,
|
|
440
|
+
down: int = 0,
|
|
441
|
+
left: int = 0,
|
|
442
|
+
up: int = 0,
|
|
443
|
+
) -> str:
|
|
444
|
+
"""Expand a cell into a range using CellRange.expand().
|
|
445
|
+
|
|
446
|
+
Pure function - no file required.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
cell: Starting cell reference (e.g., "A1")
|
|
450
|
+
right: Expand right by N columns
|
|
451
|
+
down: Expand down by N rows
|
|
452
|
+
left: Expand left by N columns
|
|
453
|
+
up: Expand up by N rows
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Range reference (e.g., "A1:F6")
|
|
457
|
+
|
|
458
|
+
Example:
|
|
459
|
+
excel.cell_range(cell="A1", right=5, down=5) # -> "A1:F6"
|
|
460
|
+
excel.cell_range(cell="C3", left=2, up=2) # -> "A1:C3"
|
|
461
|
+
"""
|
|
462
|
+
with LogSpan(span="excel.cell_range", cell=cell, right=right, down=down) as s:
|
|
463
|
+
try:
|
|
464
|
+
r = CellRange(cell)
|
|
465
|
+
r.expand(right=right, down=down, left=left, up=up)
|
|
466
|
+
result = r.coord
|
|
467
|
+
s.add(result=result)
|
|
468
|
+
return result
|
|
469
|
+
except Exception as e:
|
|
470
|
+
s.add(error=str(e))
|
|
471
|
+
return f"Error: {e}"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def cell_shift(
|
|
475
|
+
*,
|
|
476
|
+
cell: str,
|
|
477
|
+
rows: int = 0,
|
|
478
|
+
cols: int = 0,
|
|
479
|
+
) -> str:
|
|
480
|
+
"""Shift a cell reference using CellRange.shift().
|
|
481
|
+
|
|
482
|
+
Pure function - no file required.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
cell: Starting cell reference (e.g., "A1")
|
|
486
|
+
rows: Rows to shift (positive=down, negative=up)
|
|
487
|
+
cols: Columns to shift (positive=right, negative=left)
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
New cell reference
|
|
491
|
+
|
|
492
|
+
Example:
|
|
493
|
+
excel.cell_shift(cell="A1", rows=5) # -> "A6"
|
|
494
|
+
excel.cell_shift(cell="A1", cols=5) # -> "F1"
|
|
495
|
+
excel.cell_shift(cell="B3", rows=2, cols=3) # -> "E5"
|
|
496
|
+
"""
|
|
497
|
+
with LogSpan(span="excel.cell_shift", cell=cell, rows=rows, cols=cols) as s:
|
|
498
|
+
try:
|
|
499
|
+
r = CellRange(cell)
|
|
500
|
+
r.shift(row_shift=rows, col_shift=cols)
|
|
501
|
+
result = r.coord
|
|
502
|
+
s.add(result=result)
|
|
503
|
+
return result
|
|
504
|
+
except Exception as e:
|
|
505
|
+
s.add(error=str(e))
|
|
506
|
+
return f"Error: {e}"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# =============================================================================
|
|
510
|
+
# Tier 1: Search
|
|
511
|
+
# =============================================================================
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def search(
|
|
515
|
+
*,
|
|
516
|
+
filepath: str,
|
|
517
|
+
pattern: str,
|
|
518
|
+
sheet_name: str | None = None,
|
|
519
|
+
regex: bool = False,
|
|
520
|
+
first_only: bool = False,
|
|
521
|
+
) -> str:
|
|
522
|
+
"""Search for values matching a pattern.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
filepath: Path to Excel file
|
|
526
|
+
pattern: Search pattern (wildcards * ? if not regex)
|
|
527
|
+
sheet_name: Sheet to search (default: active sheet)
|
|
528
|
+
regex: Treat pattern as regex (default: False)
|
|
529
|
+
first_only: Return only first match (default: False)
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
JSON list of matches: [{cell: "A1", value: "found text"}, ...]
|
|
533
|
+
|
|
534
|
+
Example:
|
|
535
|
+
excel.search(filepath="data.xlsx", pattern="Error*")
|
|
536
|
+
excel.search(filepath="data.xlsx", pattern="^ID-\\\\d+$", regex=True)
|
|
537
|
+
excel.search(filepath="data.xlsx", pattern="Total", first_only=True)
|
|
538
|
+
"""
|
|
539
|
+
with LogSpan(span="excel.search", filepath=filepath, pattern=pattern) as s:
|
|
540
|
+
try:
|
|
541
|
+
if not _expand_path(filepath).exists():
|
|
542
|
+
s.add(error="file_not_found")
|
|
543
|
+
return f"Error: File not found: {filepath}"
|
|
544
|
+
|
|
545
|
+
wb = load_workbook(_expand_path(filepath), data_only=True)
|
|
546
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
547
|
+
if err:
|
|
548
|
+
wb.close()
|
|
549
|
+
s.add(error="sheet_not_found")
|
|
550
|
+
return err
|
|
551
|
+
|
|
552
|
+
matches: list[dict[str, str]] = []
|
|
553
|
+
compiled_regex = re.compile(pattern) if regex else None
|
|
554
|
+
|
|
555
|
+
for row in ws.iter_rows():
|
|
556
|
+
for cell in row:
|
|
557
|
+
if cell.value is None:
|
|
558
|
+
continue
|
|
559
|
+
text = str(cell.value)
|
|
560
|
+
matched = False
|
|
561
|
+
|
|
562
|
+
if regex:
|
|
563
|
+
if compiled_regex and compiled_regex.search(text):
|
|
564
|
+
matched = True
|
|
565
|
+
else:
|
|
566
|
+
if fnmatch.fnmatch(text, pattern):
|
|
567
|
+
matched = True
|
|
568
|
+
|
|
569
|
+
if matched:
|
|
570
|
+
matches.append({"cell": cell.coordinate, "value": text})
|
|
571
|
+
if first_only:
|
|
572
|
+
s.add(resultCount=1)
|
|
573
|
+
return [matches[0]]
|
|
574
|
+
|
|
575
|
+
wb.close()
|
|
576
|
+
s.add(resultCount=len(matches))
|
|
577
|
+
return matches
|
|
578
|
+
except Exception as e:
|
|
579
|
+
s.add(error=str(e))
|
|
580
|
+
return f"Error: {e}"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# =============================================================================
|
|
584
|
+
# Tier 1: Table Access
|
|
585
|
+
# =============================================================================
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def tables(
|
|
589
|
+
*,
|
|
590
|
+
filepath: str,
|
|
591
|
+
sheet_name: str | None = None,
|
|
592
|
+
) -> str:
|
|
593
|
+
"""List all defined tables in worksheet.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
filepath: Path to Excel file
|
|
597
|
+
sheet_name: Sheet to inspect (default: active sheet)
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
JSON list of table info: [{name, ref}, ...]
|
|
601
|
+
|
|
602
|
+
Example:
|
|
603
|
+
excel.tables(filepath="sales.xlsx")
|
|
604
|
+
"""
|
|
605
|
+
with LogSpan(span="excel.tables", filepath=filepath) as s:
|
|
606
|
+
try:
|
|
607
|
+
if not _expand_path(filepath).exists():
|
|
608
|
+
s.add(error="file_not_found")
|
|
609
|
+
return f"Error: File not found: {filepath}"
|
|
610
|
+
|
|
611
|
+
wb = load_workbook(_expand_path(filepath))
|
|
612
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
613
|
+
if err:
|
|
614
|
+
wb.close()
|
|
615
|
+
s.add(error="sheet_not_found")
|
|
616
|
+
return err
|
|
617
|
+
|
|
618
|
+
table_list = [
|
|
619
|
+
{"name": table.name, "ref": table.ref} for table in ws.tables.values()
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
wb.close()
|
|
623
|
+
s.add(resultCount=len(table_list))
|
|
624
|
+
return table_list
|
|
625
|
+
except Exception as e:
|
|
626
|
+
s.add(error=str(e))
|
|
627
|
+
return f"Error: {e}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def table_info(
|
|
631
|
+
*,
|
|
632
|
+
filepath: str,
|
|
633
|
+
table_name: str,
|
|
634
|
+
sheet_name: str | None = None,
|
|
635
|
+
) -> str:
|
|
636
|
+
"""Get detailed table information.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
filepath: Path to Excel file
|
|
640
|
+
table_name: Name of the table
|
|
641
|
+
sheet_name: Sheet containing table (default: active sheet)
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
JSON dict: name, ref, headers, row_count, has_totals
|
|
645
|
+
|
|
646
|
+
Example:
|
|
647
|
+
excel.table_info(filepath="sales.xlsx", table_name="SalesData")
|
|
648
|
+
"""
|
|
649
|
+
with LogSpan(span="excel.table_info", filepath=filepath, table=table_name) as s:
|
|
650
|
+
try:
|
|
651
|
+
if not _expand_path(filepath).exists():
|
|
652
|
+
s.add(error="file_not_found")
|
|
653
|
+
return f"Error: File not found: {filepath}"
|
|
654
|
+
|
|
655
|
+
wb = load_workbook(_expand_path(filepath))
|
|
656
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
657
|
+
if err:
|
|
658
|
+
wb.close()
|
|
659
|
+
s.add(error="sheet_not_found")
|
|
660
|
+
return err
|
|
661
|
+
|
|
662
|
+
if table_name not in ws.tables:
|
|
663
|
+
s.add(error="table_not_found")
|
|
664
|
+
wb.close()
|
|
665
|
+
return f"Error: Table '{table_name}' not found"
|
|
666
|
+
|
|
667
|
+
table = ws.tables[table_name]
|
|
668
|
+
# Parse ref to get row count
|
|
669
|
+
ref_range = CellRange(table.ref)
|
|
670
|
+
data_rows = ref_range.max_row - ref_range.min_row # Excludes header
|
|
671
|
+
|
|
672
|
+
info_dict = {
|
|
673
|
+
"name": table.name,
|
|
674
|
+
"ref": table.ref,
|
|
675
|
+
"headers": list(table.column_names) if table.column_names else [],
|
|
676
|
+
"row_count": data_rows,
|
|
677
|
+
"has_totals": table.totalsRowCount > 0
|
|
678
|
+
if table.totalsRowCount
|
|
679
|
+
else False,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
wb.close()
|
|
683
|
+
s.add(found=True)
|
|
684
|
+
return info_dict
|
|
685
|
+
except Exception as e:
|
|
686
|
+
s.add(error=str(e))
|
|
687
|
+
return f"Error: {e}"
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def table_data(
|
|
691
|
+
*,
|
|
692
|
+
filepath: str,
|
|
693
|
+
table_name: str,
|
|
694
|
+
row_index: int | None = None,
|
|
695
|
+
sheet_name: str | None = None,
|
|
696
|
+
) -> str:
|
|
697
|
+
"""Get table data with optional row selection.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
filepath: Path to Excel file
|
|
701
|
+
table_name: Name of the table
|
|
702
|
+
row_index: Specific row (0-indexed, excludes header). None = all rows
|
|
703
|
+
sheet_name: Sheet containing table
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
Single row: JSON dict {header: value, ...}
|
|
707
|
+
All rows: JSON list of dicts
|
|
708
|
+
|
|
709
|
+
Example:
|
|
710
|
+
excel.table_data(filepath="sales.xlsx", table_name="SalesData")
|
|
711
|
+
excel.table_data(filepath="sales.xlsx", table_name="SalesData", row_index=0)
|
|
712
|
+
"""
|
|
713
|
+
with LogSpan(span="excel.table_data", filepath=filepath, table=table_name) as s:
|
|
714
|
+
try:
|
|
715
|
+
if not _expand_path(filepath).exists():
|
|
716
|
+
s.add(error="file_not_found")
|
|
717
|
+
return f"Error: File not found: {filepath}"
|
|
718
|
+
|
|
719
|
+
wb = load_workbook(_expand_path(filepath), data_only=True)
|
|
720
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
721
|
+
if err:
|
|
722
|
+
wb.close()
|
|
723
|
+
s.add(error="sheet_not_found")
|
|
724
|
+
return err
|
|
725
|
+
|
|
726
|
+
if table_name not in ws.tables:
|
|
727
|
+
s.add(error="table_not_found")
|
|
728
|
+
wb.close()
|
|
729
|
+
return f"Error: Table '{table_name}' not found"
|
|
730
|
+
|
|
731
|
+
table = ws.tables[table_name]
|
|
732
|
+
headers = list(table.column_names) if table.column_names else []
|
|
733
|
+
ref_range = CellRange(table.ref)
|
|
734
|
+
|
|
735
|
+
# Read data rows (skip header row)
|
|
736
|
+
rows_data = []
|
|
737
|
+
for row in ws.iter_rows(
|
|
738
|
+
min_row=ref_range.min_row + 1,
|
|
739
|
+
max_row=ref_range.max_row,
|
|
740
|
+
min_col=ref_range.min_col,
|
|
741
|
+
max_col=ref_range.max_col,
|
|
742
|
+
):
|
|
743
|
+
row_dict = {}
|
|
744
|
+
for col_idx, cell in enumerate(row):
|
|
745
|
+
header = (
|
|
746
|
+
headers[col_idx] if col_idx < len(headers) else f"col_{col_idx}"
|
|
747
|
+
)
|
|
748
|
+
row_dict[header] = cell.value if cell.value is not None else ""
|
|
749
|
+
rows_data.append(row_dict)
|
|
750
|
+
|
|
751
|
+
wb.close()
|
|
752
|
+
|
|
753
|
+
if row_index is not None:
|
|
754
|
+
if 0 <= row_index < len(rows_data):
|
|
755
|
+
s.add(resultCount=1)
|
|
756
|
+
return rows_data[row_index]
|
|
757
|
+
else:
|
|
758
|
+
s.add(error="row_index_out_of_range")
|
|
759
|
+
return f"Error: Row index {row_index} out of range (0-{len(rows_data) - 1})"
|
|
760
|
+
|
|
761
|
+
s.add(resultCount=len(rows_data))
|
|
762
|
+
return rows_data
|
|
763
|
+
except Exception as e:
|
|
764
|
+
s.add(error=str(e))
|
|
765
|
+
return f"Error: {e}"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# =============================================================================
|
|
769
|
+
# Tier 2: Structure Manipulation
|
|
770
|
+
# =============================================================================
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def insert_rows(
|
|
774
|
+
*,
|
|
775
|
+
filepath: str,
|
|
776
|
+
row: int,
|
|
777
|
+
count: int = 1,
|
|
778
|
+
sheet_name: str | None = None,
|
|
779
|
+
) -> str:
|
|
780
|
+
"""Insert rows at specified position.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
filepath: Path to Excel file
|
|
784
|
+
row: Row number to insert at (1-based)
|
|
785
|
+
count: Number of rows to insert (default: 1)
|
|
786
|
+
sheet_name: Sheet to modify (default: active sheet)
|
|
787
|
+
|
|
788
|
+
Returns:
|
|
789
|
+
Success message
|
|
790
|
+
|
|
791
|
+
Example:
|
|
792
|
+
excel.insert_rows(filepath="data.xlsx", row=5, count=3)
|
|
793
|
+
"""
|
|
794
|
+
with LogSpan(span="excel.insert_rows", filepath=filepath, row=row, count=count) as s:
|
|
795
|
+
try:
|
|
796
|
+
if not _expand_path(filepath).exists():
|
|
797
|
+
s.add(error="file_not_found")
|
|
798
|
+
return f"Error: File not found: {filepath}"
|
|
799
|
+
|
|
800
|
+
wb = load_workbook(_expand_path(filepath))
|
|
801
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
802
|
+
if err:
|
|
803
|
+
wb.close()
|
|
804
|
+
s.add(error="sheet_not_found")
|
|
805
|
+
return err
|
|
806
|
+
|
|
807
|
+
ws.insert_rows(row, count)
|
|
808
|
+
wb.save(_expand_path(filepath))
|
|
809
|
+
wb.close()
|
|
810
|
+
s.add(inserted=count)
|
|
811
|
+
return f"Inserted {count} {_plural(count, 'row')} at row {row}"
|
|
812
|
+
except Exception as e:
|
|
813
|
+
s.add(error=str(e))
|
|
814
|
+
return f"Error: {e}"
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def delete_rows(
|
|
818
|
+
*,
|
|
819
|
+
filepath: str,
|
|
820
|
+
row: int,
|
|
821
|
+
count: int = 1,
|
|
822
|
+
sheet_name: str | None = None,
|
|
823
|
+
) -> str:
|
|
824
|
+
"""Delete rows starting at specified position.
|
|
825
|
+
|
|
826
|
+
Args:
|
|
827
|
+
filepath: Path to Excel file
|
|
828
|
+
row: Row number to start deleting (1-based)
|
|
829
|
+
count: Number of rows to delete (default: 1)
|
|
830
|
+
sheet_name: Sheet to modify (default: active sheet)
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
Success message
|
|
834
|
+
|
|
835
|
+
Example:
|
|
836
|
+
excel.delete_rows(filepath="data.xlsx", row=3, count=2)
|
|
837
|
+
"""
|
|
838
|
+
with LogSpan(span="excel.delete_rows", filepath=filepath, row=row, count=count) as s:
|
|
839
|
+
try:
|
|
840
|
+
if not _expand_path(filepath).exists():
|
|
841
|
+
s.add(error="file_not_found")
|
|
842
|
+
return f"Error: File not found: {filepath}"
|
|
843
|
+
|
|
844
|
+
wb = load_workbook(_expand_path(filepath))
|
|
845
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
846
|
+
if err:
|
|
847
|
+
wb.close()
|
|
848
|
+
s.add(error="sheet_not_found")
|
|
849
|
+
return err
|
|
850
|
+
|
|
851
|
+
ws.delete_rows(row, count)
|
|
852
|
+
wb.save(_expand_path(filepath))
|
|
853
|
+
wb.close()
|
|
854
|
+
s.add(deleted=count)
|
|
855
|
+
return f"Deleted {count} {_plural(count, 'row')} starting at row {row}"
|
|
856
|
+
except Exception as e:
|
|
857
|
+
s.add(error=str(e))
|
|
858
|
+
return f"Error: {e}"
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def insert_cols(
|
|
862
|
+
*,
|
|
863
|
+
filepath: str,
|
|
864
|
+
col: int | str,
|
|
865
|
+
count: int = 1,
|
|
866
|
+
sheet_name: str | None = None,
|
|
867
|
+
) -> str:
|
|
868
|
+
"""Insert columns at specified position.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
filepath: Path to Excel file
|
|
872
|
+
col: Column number (1-based) or letter ("A", "B", etc.)
|
|
873
|
+
count: Number of columns to insert (default: 1)
|
|
874
|
+
sheet_name: Sheet to modify (default: active sheet)
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
Success message
|
|
878
|
+
|
|
879
|
+
Example:
|
|
880
|
+
excel.insert_cols(filepath="data.xlsx", col="C", count=2)
|
|
881
|
+
excel.insert_cols(filepath="data.xlsx", col=3, count=2)
|
|
882
|
+
"""
|
|
883
|
+
with LogSpan(span="excel.insert_cols", filepath=filepath, col=col, count=count) as s:
|
|
884
|
+
try:
|
|
885
|
+
if not _expand_path(filepath).exists():
|
|
886
|
+
s.add(error="file_not_found")
|
|
887
|
+
return f"Error: File not found: {filepath}"
|
|
888
|
+
|
|
889
|
+
wb = load_workbook(_expand_path(filepath))
|
|
890
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
891
|
+
if err:
|
|
892
|
+
wb.close()
|
|
893
|
+
s.add(error="sheet_not_found")
|
|
894
|
+
return err
|
|
895
|
+
|
|
896
|
+
col_idx = _col_to_index(col)
|
|
897
|
+
col_letter = get_column_letter(col_idx)
|
|
898
|
+
ws.insert_cols(col_idx, count)
|
|
899
|
+
wb.save(_expand_path(filepath))
|
|
900
|
+
wb.close()
|
|
901
|
+
s.add(inserted=count)
|
|
902
|
+
return f"Inserted {count} {_plural(count, 'column')} at column {col_letter}"
|
|
903
|
+
except Exception as e:
|
|
904
|
+
s.add(error=str(e))
|
|
905
|
+
return f"Error: {e}"
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def delete_cols(
|
|
909
|
+
*,
|
|
910
|
+
filepath: str,
|
|
911
|
+
col: int | str,
|
|
912
|
+
count: int = 1,
|
|
913
|
+
sheet_name: str | None = None,
|
|
914
|
+
) -> str:
|
|
915
|
+
"""Delete columns starting at specified position.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
filepath: Path to Excel file
|
|
919
|
+
col: Column number (1-based) or letter ("A", "B", etc.)
|
|
920
|
+
count: Number of columns to delete (default: 1)
|
|
921
|
+
sheet_name: Sheet to modify (default: active sheet)
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
Success message
|
|
925
|
+
|
|
926
|
+
Example:
|
|
927
|
+
excel.delete_cols(filepath="data.xlsx", col="B", count=2)
|
|
928
|
+
"""
|
|
929
|
+
with LogSpan(span="excel.delete_cols", filepath=filepath, col=col, count=count) as s:
|
|
930
|
+
try:
|
|
931
|
+
if not _expand_path(filepath).exists():
|
|
932
|
+
s.add(error="file_not_found")
|
|
933
|
+
return f"Error: File not found: {filepath}"
|
|
934
|
+
|
|
935
|
+
wb = load_workbook(_expand_path(filepath))
|
|
936
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
937
|
+
if err:
|
|
938
|
+
wb.close()
|
|
939
|
+
s.add(error="sheet_not_found")
|
|
940
|
+
return err
|
|
941
|
+
|
|
942
|
+
col_idx = _col_to_index(col)
|
|
943
|
+
col_letter = get_column_letter(col_idx)
|
|
944
|
+
ws.delete_cols(col_idx, count)
|
|
945
|
+
wb.save(_expand_path(filepath))
|
|
946
|
+
wb.close()
|
|
947
|
+
s.add(deleted=count)
|
|
948
|
+
return f"Deleted {count} {_plural(count, 'column')} starting at column {col_letter}"
|
|
949
|
+
except Exception as e:
|
|
950
|
+
s.add(error=str(e))
|
|
951
|
+
return f"Error: {e}"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def copy_range(
|
|
955
|
+
*,
|
|
956
|
+
filepath: str,
|
|
957
|
+
source: str,
|
|
958
|
+
target: str,
|
|
959
|
+
sheet_name: str | None = None,
|
|
960
|
+
target_sheet: str | None = None,
|
|
961
|
+
) -> str:
|
|
962
|
+
"""Copy a range to another location.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
filepath: Path to Excel file
|
|
966
|
+
source: Source range (e.g., "A1:C10")
|
|
967
|
+
target: Target cell (top-left of destination)
|
|
968
|
+
sheet_name: Source sheet (default: active sheet)
|
|
969
|
+
target_sheet: Target sheet (default: same as source)
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
Success message
|
|
973
|
+
|
|
974
|
+
Example:
|
|
975
|
+
excel.copy_range(filepath="data.xlsx", source="A1:C10", target="E1")
|
|
976
|
+
excel.copy_range(filepath="data.xlsx", source="A1:C10", target="A1", target_sheet="Backup")
|
|
977
|
+
"""
|
|
978
|
+
with LogSpan(span="excel.copy_range", filepath=filepath, source=source, target=target) as s:
|
|
979
|
+
try:
|
|
980
|
+
if not _expand_path(filepath).exists():
|
|
981
|
+
s.add(error="file_not_found")
|
|
982
|
+
return f"Error: File not found: {filepath}"
|
|
983
|
+
|
|
984
|
+
wb = load_workbook(_expand_path(filepath))
|
|
985
|
+
ws_source, err = _get_sheet(wb, sheet_name)
|
|
986
|
+
if err:
|
|
987
|
+
wb.close()
|
|
988
|
+
s.add(error="sheet_not_found")
|
|
989
|
+
return err
|
|
990
|
+
|
|
991
|
+
# Get target worksheet
|
|
992
|
+
if target_sheet:
|
|
993
|
+
ws_target, err = _get_sheet(wb, target_sheet)
|
|
994
|
+
if err:
|
|
995
|
+
wb.close()
|
|
996
|
+
s.add(error="target_sheet_not_found")
|
|
997
|
+
return err
|
|
998
|
+
else:
|
|
999
|
+
ws_target = ws_source
|
|
1000
|
+
|
|
1001
|
+
# Parse source range
|
|
1002
|
+
source_range = CellRange(source)
|
|
1003
|
+
# Parse target cell
|
|
1004
|
+
target_col_letter, target_row = coordinate_from_string(target)
|
|
1005
|
+
target_col = column_index_from_string(target_col_letter)
|
|
1006
|
+
|
|
1007
|
+
# Copy cells
|
|
1008
|
+
for row_offset, row in enumerate(
|
|
1009
|
+
ws_source.iter_rows(
|
|
1010
|
+
min_row=source_range.min_row,
|
|
1011
|
+
max_row=source_range.max_row,
|
|
1012
|
+
min_col=source_range.min_col,
|
|
1013
|
+
max_col=source_range.max_col,
|
|
1014
|
+
)
|
|
1015
|
+
):
|
|
1016
|
+
for col_offset, cell in enumerate(row):
|
|
1017
|
+
ws_target.cell(
|
|
1018
|
+
row=target_row + row_offset,
|
|
1019
|
+
column=target_col + col_offset,
|
|
1020
|
+
value=cell.value,
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
# Calculate destination range for message
|
|
1024
|
+
dest_end_col = get_column_letter(
|
|
1025
|
+
target_col + source_range.max_col - source_range.min_col
|
|
1026
|
+
)
|
|
1027
|
+
dest_end_row = target_row + source_range.max_row - source_range.min_row
|
|
1028
|
+
dest_range = f"{target}:{dest_end_col}{dest_end_row}"
|
|
1029
|
+
|
|
1030
|
+
wb.save(_expand_path(filepath))
|
|
1031
|
+
wb.close()
|
|
1032
|
+
s.add(copied=True)
|
|
1033
|
+
return f"Copied {source} to {dest_range}"
|
|
1034
|
+
except Exception as e:
|
|
1035
|
+
s.add(error=str(e))
|
|
1036
|
+
return f"Error: {e}"
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def create_table(
|
|
1040
|
+
*,
|
|
1041
|
+
filepath: str,
|
|
1042
|
+
data_range: str,
|
|
1043
|
+
table_name: str | None = None,
|
|
1044
|
+
sheet_name: str | None = None,
|
|
1045
|
+
) -> str:
|
|
1046
|
+
"""Create a native Excel table from a data range.
|
|
1047
|
+
|
|
1048
|
+
Tables enable filtering, sorting, and structured references.
|
|
1049
|
+
First row of range is used as headers.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
filepath: Path to Excel file
|
|
1053
|
+
data_range: Range containing data (e.g., "A1:E10")
|
|
1054
|
+
table_name: Name for the table (default: auto-generated)
|
|
1055
|
+
sheet_name: Sheet containing data (default: active sheet)
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
Success message
|
|
1059
|
+
|
|
1060
|
+
Example:
|
|
1061
|
+
excel.create_table(filepath="sales.xlsx", data_range="A1:E10", table_name="SalesData")
|
|
1062
|
+
"""
|
|
1063
|
+
with LogSpan(span="excel.create_table", filepath=filepath, range=data_range) as s:
|
|
1064
|
+
try:
|
|
1065
|
+
if not _expand_path(filepath).exists():
|
|
1066
|
+
s.add(error="file_not_found")
|
|
1067
|
+
return f"Error: File not found: {filepath}"
|
|
1068
|
+
|
|
1069
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1070
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
1071
|
+
if err:
|
|
1072
|
+
wb.close()
|
|
1073
|
+
s.add(error="sheet_not_found")
|
|
1074
|
+
return err
|
|
1075
|
+
|
|
1076
|
+
# Generate table name if not provided
|
|
1077
|
+
if table_name is None:
|
|
1078
|
+
existing_tables = set(ws.tables.keys())
|
|
1079
|
+
counter = 1
|
|
1080
|
+
while f"Table{counter}" in existing_tables:
|
|
1081
|
+
counter += 1
|
|
1082
|
+
table_name = f"Table{counter}"
|
|
1083
|
+
|
|
1084
|
+
# Create table with default style
|
|
1085
|
+
table = Table(displayName=table_name, ref=data_range)
|
|
1086
|
+
style = TableStyleInfo(
|
|
1087
|
+
name="TableStyleMedium2",
|
|
1088
|
+
showFirstColumn=False,
|
|
1089
|
+
showLastColumn=False,
|
|
1090
|
+
showRowStripes=True,
|
|
1091
|
+
showColumnStripes=False,
|
|
1092
|
+
)
|
|
1093
|
+
table.tableStyleInfo = style
|
|
1094
|
+
ws.add_table(table)
|
|
1095
|
+
|
|
1096
|
+
wb.save(_expand_path(filepath))
|
|
1097
|
+
wb.close()
|
|
1098
|
+
s.add(created=table_name)
|
|
1099
|
+
return f"Created table '{table_name}' from {data_range}"
|
|
1100
|
+
except Exception as e:
|
|
1101
|
+
s.add(error=str(e))
|
|
1102
|
+
return f"Error: {e}"
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# =============================================================================
|
|
1106
|
+
# Tier 3: Extended Inspection
|
|
1107
|
+
# =============================================================================
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def sheets(*, filepath: str) -> str:
|
|
1111
|
+
"""List all sheets with visibility and type.
|
|
1112
|
+
|
|
1113
|
+
Args:
|
|
1114
|
+
filepath: Path to Excel file
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
JSON list: [{name, state}, ...]
|
|
1118
|
+
state: 'visible', 'hidden', 'veryHidden'
|
|
1119
|
+
|
|
1120
|
+
Example:
|
|
1121
|
+
excel.sheets(filepath="report.xlsx")
|
|
1122
|
+
"""
|
|
1123
|
+
with LogSpan(span="excel.sheets", filepath=filepath) as s:
|
|
1124
|
+
try:
|
|
1125
|
+
if not _expand_path(filepath).exists():
|
|
1126
|
+
s.add(error="file_not_found")
|
|
1127
|
+
return f"Error: File not found: {filepath}"
|
|
1128
|
+
|
|
1129
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1130
|
+
sheet_list = []
|
|
1131
|
+
for name in wb.sheetnames:
|
|
1132
|
+
ws = wb[name]
|
|
1133
|
+
state = ws.sheet_state if ws.sheet_state else "visible"
|
|
1134
|
+
sheet_list.append({"name": name, "state": state})
|
|
1135
|
+
|
|
1136
|
+
wb.close()
|
|
1137
|
+
s.add(resultCount=len(sheet_list))
|
|
1138
|
+
return sheet_list
|
|
1139
|
+
except Exception as e:
|
|
1140
|
+
s.add(error=str(e))
|
|
1141
|
+
return f"Error: {e}"
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def used_range(
|
|
1145
|
+
*,
|
|
1146
|
+
filepath: str,
|
|
1147
|
+
sheet_name: str | None = None,
|
|
1148
|
+
) -> str:
|
|
1149
|
+
"""Get the used range of a worksheet.
|
|
1150
|
+
|
|
1151
|
+
Args:
|
|
1152
|
+
filepath: Path to Excel file
|
|
1153
|
+
sheet_name: Sheet to inspect (default: active sheet)
|
|
1154
|
+
|
|
1155
|
+
Returns:
|
|
1156
|
+
Range reference (e.g., "A1:Z100") or "empty"
|
|
1157
|
+
|
|
1158
|
+
Example:
|
|
1159
|
+
excel.used_range(filepath="data.xlsx")
|
|
1160
|
+
"""
|
|
1161
|
+
with LogSpan(span="excel.used_range", filepath=filepath) as s:
|
|
1162
|
+
try:
|
|
1163
|
+
if not _expand_path(filepath).exists():
|
|
1164
|
+
s.add(error="file_not_found")
|
|
1165
|
+
return f"Error: File not found: {filepath}"
|
|
1166
|
+
|
|
1167
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1168
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
1169
|
+
if err:
|
|
1170
|
+
wb.close()
|
|
1171
|
+
s.add(error="sheet_not_found")
|
|
1172
|
+
return err
|
|
1173
|
+
|
|
1174
|
+
if ws.max_row and ws.max_column:
|
|
1175
|
+
end_col = get_column_letter(ws.max_column)
|
|
1176
|
+
result = f"A1:{end_col}{ws.max_row}"
|
|
1177
|
+
else:
|
|
1178
|
+
result = "empty"
|
|
1179
|
+
|
|
1180
|
+
wb.close()
|
|
1181
|
+
s.add(result=result)
|
|
1182
|
+
return result
|
|
1183
|
+
except Exception as e:
|
|
1184
|
+
s.add(error=str(e))
|
|
1185
|
+
return f"Error: {e}"
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def formulas(
|
|
1189
|
+
*,
|
|
1190
|
+
filepath: str,
|
|
1191
|
+
sheet_name: str | None = None,
|
|
1192
|
+
) -> str:
|
|
1193
|
+
"""List all cells containing formulas.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
filepath: Path to Excel file
|
|
1197
|
+
sheet_name: Sheet to inspect (default: active sheet)
|
|
1198
|
+
|
|
1199
|
+
Returns:
|
|
1200
|
+
JSON list: [{cell, formula}, ...]
|
|
1201
|
+
|
|
1202
|
+
Example:
|
|
1203
|
+
excel.formulas(filepath="calc.xlsx")
|
|
1204
|
+
"""
|
|
1205
|
+
with LogSpan(span="excel.formulas", filepath=filepath) as s:
|
|
1206
|
+
try:
|
|
1207
|
+
if not _expand_path(filepath).exists():
|
|
1208
|
+
s.add(error="file_not_found")
|
|
1209
|
+
return f"Error: File not found: {filepath}"
|
|
1210
|
+
|
|
1211
|
+
# Don't use data_only to preserve formulas
|
|
1212
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1213
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
1214
|
+
if err:
|
|
1215
|
+
wb.close()
|
|
1216
|
+
s.add(error="sheet_not_found")
|
|
1217
|
+
return err
|
|
1218
|
+
|
|
1219
|
+
formula_list = []
|
|
1220
|
+
for row in ws.iter_rows():
|
|
1221
|
+
for cell in row:
|
|
1222
|
+
if cell.data_type == "f" or (
|
|
1223
|
+
isinstance(cell.value, str) and cell.value.startswith("=")
|
|
1224
|
+
):
|
|
1225
|
+
formula_list.append(
|
|
1226
|
+
{
|
|
1227
|
+
"cell": cell.coordinate,
|
|
1228
|
+
"formula": cell.value,
|
|
1229
|
+
}
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
wb.close()
|
|
1233
|
+
s.add(resultCount=len(formula_list))
|
|
1234
|
+
return formula_list
|
|
1235
|
+
except Exception as e:
|
|
1236
|
+
s.add(error=str(e))
|
|
1237
|
+
return f"Error: {e}"
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def hyperlinks(
|
|
1241
|
+
*,
|
|
1242
|
+
filepath: str,
|
|
1243
|
+
sheet_name: str | None = None,
|
|
1244
|
+
) -> str:
|
|
1245
|
+
"""List all hyperlinks in worksheet.
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
filepath: Path to Excel file
|
|
1249
|
+
sheet_name: Sheet to inspect (default: active sheet)
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
JSON list: [{cell, target, display}, ...]
|
|
1253
|
+
|
|
1254
|
+
Example:
|
|
1255
|
+
excel.hyperlinks(filepath="links.xlsx")
|
|
1256
|
+
"""
|
|
1257
|
+
with LogSpan(span="excel.hyperlinks", filepath=filepath) as s:
|
|
1258
|
+
try:
|
|
1259
|
+
if not _expand_path(filepath).exists():
|
|
1260
|
+
s.add(error="file_not_found")
|
|
1261
|
+
return f"Error: File not found: {filepath}"
|
|
1262
|
+
|
|
1263
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1264
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
1265
|
+
if err:
|
|
1266
|
+
wb.close()
|
|
1267
|
+
s.add(error="sheet_not_found")
|
|
1268
|
+
return err
|
|
1269
|
+
|
|
1270
|
+
link_list = []
|
|
1271
|
+
for row in ws.iter_rows():
|
|
1272
|
+
for cell in row:
|
|
1273
|
+
if cell.hyperlink:
|
|
1274
|
+
link_list.append(
|
|
1275
|
+
{
|
|
1276
|
+
"cell": cell.coordinate,
|
|
1277
|
+
"target": cell.hyperlink.target or "",
|
|
1278
|
+
"display": str(cell.value) if cell.value else "",
|
|
1279
|
+
}
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
wb.close()
|
|
1283
|
+
s.add(resultCount=len(link_list))
|
|
1284
|
+
return link_list
|
|
1285
|
+
except Exception as e:
|
|
1286
|
+
s.add(error=str(e))
|
|
1287
|
+
return f"Error: {e}"
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def merged_cells(
|
|
1291
|
+
*,
|
|
1292
|
+
filepath: str,
|
|
1293
|
+
sheet_name: str | None = None,
|
|
1294
|
+
) -> str:
|
|
1295
|
+
"""List merged cell ranges in worksheet.
|
|
1296
|
+
|
|
1297
|
+
Args:
|
|
1298
|
+
filepath: Path to Excel file
|
|
1299
|
+
sheet_name: Sheet to inspect (default: active sheet)
|
|
1300
|
+
|
|
1301
|
+
Returns:
|
|
1302
|
+
JSON list of range strings: ["B2:F4", "A10:C10", ...]
|
|
1303
|
+
|
|
1304
|
+
Example:
|
|
1305
|
+
excel.merged_cells(filepath="report.xlsx")
|
|
1306
|
+
"""
|
|
1307
|
+
with LogSpan(span="excel.merged_cells", filepath=filepath) as s:
|
|
1308
|
+
try:
|
|
1309
|
+
if not _expand_path(filepath).exists():
|
|
1310
|
+
s.add(error="file_not_found")
|
|
1311
|
+
return f"Error: File not found: {filepath}"
|
|
1312
|
+
|
|
1313
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1314
|
+
ws, err = _get_sheet(wb, sheet_name)
|
|
1315
|
+
if err:
|
|
1316
|
+
wb.close()
|
|
1317
|
+
s.add(error="sheet_not_found")
|
|
1318
|
+
return err
|
|
1319
|
+
|
|
1320
|
+
merged_list = [str(r) for r in ws.merged_cells.ranges]
|
|
1321
|
+
|
|
1322
|
+
wb.close()
|
|
1323
|
+
s.add(resultCount=len(merged_list))
|
|
1324
|
+
return merged_list
|
|
1325
|
+
except Exception as e:
|
|
1326
|
+
s.add(error=str(e))
|
|
1327
|
+
return f"Error: {e}"
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def named_ranges(*, filepath: str) -> str:
|
|
1331
|
+
"""List all named ranges in workbook.
|
|
1332
|
+
|
|
1333
|
+
Args:
|
|
1334
|
+
filepath: Path to Excel file
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
JSON list: [{name, value, destinations}, ...]
|
|
1338
|
+
|
|
1339
|
+
Example:
|
|
1340
|
+
excel.named_ranges(filepath="report.xlsx")
|
|
1341
|
+
"""
|
|
1342
|
+
with LogSpan(span="excel.named_ranges", filepath=filepath) as s:
|
|
1343
|
+
try:
|
|
1344
|
+
if not _expand_path(filepath).exists():
|
|
1345
|
+
s.add(error="file_not_found")
|
|
1346
|
+
return f"Error: File not found: {filepath}"
|
|
1347
|
+
|
|
1348
|
+
wb = load_workbook(_expand_path(filepath))
|
|
1349
|
+
range_list = []
|
|
1350
|
+
|
|
1351
|
+
for defn in wb.defined_names.values():
|
|
1352
|
+
destinations = []
|
|
1353
|
+
try:
|
|
1354
|
+
for sheet_title, cell_range in defn.destinations:
|
|
1355
|
+
destinations.append(f"{sheet_title}!{cell_range}")
|
|
1356
|
+
except Exception:
|
|
1357
|
+
pass
|
|
1358
|
+
|
|
1359
|
+
range_list.append(
|
|
1360
|
+
{
|
|
1361
|
+
"name": defn.name,
|
|
1362
|
+
"value": defn.attr_text,
|
|
1363
|
+
"destinations": destinations,
|
|
1364
|
+
}
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
wb.close()
|
|
1368
|
+
s.add(resultCount=len(range_list))
|
|
1369
|
+
return range_list
|
|
1370
|
+
except Exception as e:
|
|
1371
|
+
s.add(error=str(e))
|
|
1372
|
+
return f"Error: {e}"
|