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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. 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}"