ExcelTamer 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exceltamer-0.2.0/ExcelTamer/__init__.py +1 -0
- exceltamer-0.2.0/ExcelTamer/mcp/__init__.py +0 -0
- exceltamer-0.2.0/ExcelTamer/mcp/audit.py +37 -0
- exceltamer-0.2.0/ExcelTamer/mcp/config.py +25 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/__init__.py +0 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/diff.py +131 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/read.py +109 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/search.py +101 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/workbook.py +63 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/write.py +97 -0
- exceltamer-0.2.0/ExcelTamer/mcp/excel.py +110 -0
- exceltamer-0.2.0/ExcelTamer/mcp/main.py +18 -0
- exceltamer-0.2.0/ExcelTamer/mcp/prompts/README.md +0 -0
- exceltamer-0.2.0/ExcelTamer/mcp/prompts/financial_metric_extract.md +23 -0
- exceltamer-0.2.0/ExcelTamer/mcp/prompts/safe_edit.md +26 -0
- exceltamer-0.2.0/ExcelTamer/mcp/safety.py +39 -0
- exceltamer-0.2.0/ExcelTamer/mcp/schemas/__init__.py +0 -0
- exceltamer-0.2.0/ExcelTamer/mcp/server.py +541 -0
- exceltamer-0.2.0/ExcelTamer/mcp/sessions.py +35 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/PKG-INFO +170 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/SOURCES.txt +30 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/dependency_links.txt +1 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/entry_points.txt +2 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/requires.txt +3 -0
- exceltamer-0.2.0/ExcelTamer.egg-info/top_level.txt +1 -0
- exceltamer-0.2.0/LICENSE +674 -0
- exceltamer-0.2.0/MANIFEST.in +3 -0
- exceltamer-0.2.0/PKG-INFO +170 -0
- exceltamer-0.2.0/README.md +146 -0
- exceltamer-0.2.0/pyproject.toml +45 -0
- exceltamer-0.2.0/setup.cfg +4 -0
- exceltamer-0.2.0/test/test_mcp_smoke.py +195 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ExcelTamer MCP server package."""
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .config import AUDIT_LOG_DIR
|
|
8
|
+
|
|
9
|
+
# Ensure audit directory exists
|
|
10
|
+
try:
|
|
11
|
+
Path(AUDIT_LOG_DIR).mkdir(parents=True, exist_ok=True)
|
|
12
|
+
except Exception as e:
|
|
13
|
+
# Fall back to the current directory if the configured location is unavailable.
|
|
14
|
+
print(f"Warning: Could not create audit log dir {AUDIT_LOG_DIR}: {e}")
|
|
15
|
+
|
|
16
|
+
# Configure a specific logger for audit
|
|
17
|
+
audit_logger = logging.getLogger("ExcelTamerAudit")
|
|
18
|
+
audit_logger.setLevel(logging.INFO)
|
|
19
|
+
|
|
20
|
+
# File handler for audit logs (one log file per session/day or single rotating file)
|
|
21
|
+
# For simplicity, single file appened.
|
|
22
|
+
audit_file = Path(AUDIT_LOG_DIR) / "audit.jsonl"
|
|
23
|
+
handler = logging.FileHandler(audit_file)
|
|
24
|
+
handler.setFormatter(logging.Formatter('%(message)s'))
|
|
25
|
+
audit_logger.addHandler(handler)
|
|
26
|
+
|
|
27
|
+
def log_write(tool_name: str, workbook_id: str, details: dict):
|
|
28
|
+
"""
|
|
29
|
+
Log a write operation.
|
|
30
|
+
"""
|
|
31
|
+
entry = {
|
|
32
|
+
"timestamp": datetime.now().isoformat(),
|
|
33
|
+
"tool": tool_name,
|
|
34
|
+
"workbook_id": workbook_id,
|
|
35
|
+
"details": details
|
|
36
|
+
}
|
|
37
|
+
audit_logger.info(json.dumps(entry))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def get_env_list(key, default=None):
|
|
6
|
+
val = os.environ.get(key, default)
|
|
7
|
+
if not val:
|
|
8
|
+
return []
|
|
9
|
+
return [s.strip() for s in val.split(',')]
|
|
10
|
+
|
|
11
|
+
# Security: Allowed roots for file operations
|
|
12
|
+
# If empty, defaults to current working directory for safety in basic usage,
|
|
13
|
+
# but in production should be explicit.
|
|
14
|
+
_default_root = str(Path.cwd())
|
|
15
|
+
ALLOWED_ROOTS = get_env_list("EXCELTAMER_MCP_ALLOWED_ROOTS", _default_root)
|
|
16
|
+
|
|
17
|
+
# Limits
|
|
18
|
+
MAX_CELLS_READ = int(os.environ.get("EXCELTAMER_MCP_MAX_CELLS_READ", "20000"))
|
|
19
|
+
MAX_CELLS_WRITE = int(os.environ.get("EXCELTAMER_MCP_MAX_CELLS_WRITE", "5000"))
|
|
20
|
+
|
|
21
|
+
# Defaults
|
|
22
|
+
DEFAULT_MODE = os.environ.get("EXCELTAMER_MCP_DEFAULT_MODE", "ro")
|
|
23
|
+
|
|
24
|
+
# Audit
|
|
25
|
+
AUDIT_LOG_DIR = os.environ.get("EXCELTAMER_MCP_AUDIT_LOG_DIR", "./.exceltamer_mcp_logs")
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
|
|
2
|
+
import shutil
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import gettempdir
|
|
6
|
+
from typing import Dict
|
|
7
|
+
from ..excel import ExcelAutomation
|
|
8
|
+
from ..sessions import session
|
|
9
|
+
|
|
10
|
+
# We'll store checkpoint paths in session memory
|
|
11
|
+
# workbook_id -> {checkpoint_name -> file_path}
|
|
12
|
+
checkpoints: Dict[str, Dict[str, str]] = {}
|
|
13
|
+
|
|
14
|
+
def _get_checkpoint_dir():
|
|
15
|
+
# Store checkpoints in a temp dir or hidden local dir
|
|
16
|
+
base = Path(gettempdir()) / "exceltamer_checkpoints"
|
|
17
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return base
|
|
19
|
+
|
|
20
|
+
def checkpoint_create(workbook_id: str, name: str) -> dict:
|
|
21
|
+
automation = session.get_workbook(workbook_id)
|
|
22
|
+
if not automation:
|
|
23
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
24
|
+
|
|
25
|
+
# We need to save the current state to a separate file.
|
|
26
|
+
# Xlwings .save() overwrites the current file.
|
|
27
|
+
# To create a checkpoint without moving the user's active file pointer,
|
|
28
|
+
# we can save a copy.
|
|
29
|
+
|
|
30
|
+
cp_dir = _get_checkpoint_dir()
|
|
31
|
+
cp_filename = f"{workbook_id}_{name}_{uuid.uuid4().hex[:8]}.xlsx"
|
|
32
|
+
cp_path = cp_dir / cp_filename
|
|
33
|
+
|
|
34
|
+
# Save a copy
|
|
35
|
+
# automation.wb.save(path) changes the active workbook to that path in Excel UI usually?
|
|
36
|
+
# Let's check xlwings docs/behavior.
|
|
37
|
+
# wb.save(path) "Saves the Workbook to the specified filename." -> Effectively Save As.
|
|
38
|
+
# If we do that, our session is now pointing to the checkpoint file? Yes.
|
|
39
|
+
# We want to stay on the main file but snapshot it.
|
|
40
|
+
|
|
41
|
+
# Workaround:
|
|
42
|
+
# 1. Save current workbook to disk (ensure it's up to date).
|
|
43
|
+
automation.save()
|
|
44
|
+
|
|
45
|
+
# 2. Copy the file on disk to checkpoint path.
|
|
46
|
+
# Access underlying full path
|
|
47
|
+
current_path = automation.wb.fullname
|
|
48
|
+
shutil.copy2(current_path, cp_path)
|
|
49
|
+
|
|
50
|
+
if workbook_id not in checkpoints:
|
|
51
|
+
checkpoints[workbook_id] = {}
|
|
52
|
+
checkpoints[workbook_id][name] = str(cp_path)
|
|
53
|
+
|
|
54
|
+
return {"status": "created", "name": name, "path": str(cp_path)}
|
|
55
|
+
|
|
56
|
+
def checkpoint_rollback(workbook_id: str, name: str) -> dict:
|
|
57
|
+
automation = session.get_workbook(workbook_id)
|
|
58
|
+
if not automation:
|
|
59
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
60
|
+
|
|
61
|
+
if workbook_id not in checkpoints or name not in checkpoints[workbook_id]:
|
|
62
|
+
raise ValueError(f"Checkpoint '{name}' not found for this workbook")
|
|
63
|
+
|
|
64
|
+
cp_path = checkpoints[workbook_id][name]
|
|
65
|
+
|
|
66
|
+
# Rollback strategy:
|
|
67
|
+
# 1. Close current workbook (discard changes? well we are rolling back)
|
|
68
|
+
# 2. Overwrite current workbook file with checkpoint file
|
|
69
|
+
# 3. Re-open
|
|
70
|
+
|
|
71
|
+
original_path = automation.wb.fullname
|
|
72
|
+
|
|
73
|
+
# Close
|
|
74
|
+
automation.close(quit_app=False)
|
|
75
|
+
session.remove_workbook(workbook_id)
|
|
76
|
+
|
|
77
|
+
# Overwrite
|
|
78
|
+
shutil.copy2(cp_path, original_path)
|
|
79
|
+
|
|
80
|
+
# Re-open (reuse ID?)
|
|
81
|
+
# ideally we keep the same ID for the client's sake
|
|
82
|
+
# But we need to re-init automation
|
|
83
|
+
new_automation = ExcelAutomation(file_path=original_path)
|
|
84
|
+
|
|
85
|
+
# We need to hack session to restore the ID mapping
|
|
86
|
+
session.open_workbooks[workbook_id] = new_automation
|
|
87
|
+
|
|
88
|
+
return {"status": "rolled_back", "name": name}
|
|
89
|
+
|
|
90
|
+
def preview_diff(workbook_id: str, max_changes: int = 200) -> dict:
|
|
91
|
+
"""
|
|
92
|
+
Shows a summary of changes.
|
|
93
|
+
Since we don't track cell-by-cell diffs in memory yet (complex),
|
|
94
|
+
we will rely on:
|
|
95
|
+
1. If we have a 'base' checkpoint, maybe compare? (Hard to do nicely in MVP)
|
|
96
|
+
2. Or return the Audit Log entries for this session/workbook?
|
|
97
|
+
|
|
98
|
+
Let's return the last N actions from the in-memory audit trail/session tracker?
|
|
99
|
+
We didn't implement in-memory audit trail, only file log.
|
|
100
|
+
|
|
101
|
+
Let's read the audit log file and filter by workbook_id.
|
|
102
|
+
"""
|
|
103
|
+
from ..config import AUDIT_LOG_DIR
|
|
104
|
+
import json
|
|
105
|
+
|
|
106
|
+
audit_file = Path(AUDIT_LOG_DIR) / "audit.jsonl"
|
|
107
|
+
changes = []
|
|
108
|
+
|
|
109
|
+
if audit_file.exists():
|
|
110
|
+
# Read backward optimization could be done, but for MVP read all is fine
|
|
111
|
+
with open(audit_file, "r") as f:
|
|
112
|
+
for line in f:
|
|
113
|
+
try:
|
|
114
|
+
entry = json.loads(line)
|
|
115
|
+
if entry.get("workbook_id") == workbook_id:
|
|
116
|
+
changes.append(entry)
|
|
117
|
+
except:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Sort by timestamp desc?
|
|
121
|
+
# Usually we want chronological.
|
|
122
|
+
|
|
123
|
+
# This is "History" rather than "Diff".
|
|
124
|
+
# True Diff requires comparing values.
|
|
125
|
+
# For MVP, History is a good proxy for "What have I done?".
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"summary": f"Found {len(changes)} recorded actions for this workbook.",
|
|
129
|
+
"recent_actions": changes[-max_changes:],
|
|
130
|
+
"truncated": len(changes) > max_changes
|
|
131
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from ..sessions import session
|
|
5
|
+
from ..config import MAX_CELLS_READ
|
|
6
|
+
|
|
7
|
+
def get_structure(workbook_id: str) -> dict:
|
|
8
|
+
automation = session.get_workbook(workbook_id)
|
|
9
|
+
if not automation:
|
|
10
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
11
|
+
|
|
12
|
+
structure = automation.get_structure()
|
|
13
|
+
return {
|
|
14
|
+
"workbook_id": workbook_id,
|
|
15
|
+
"structure": structure
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def query_cell(workbook_id: str, sheet: str, cell: str) -> dict:
|
|
19
|
+
automation = session.get_workbook(workbook_id)
|
|
20
|
+
if not automation:
|
|
21
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
22
|
+
|
|
23
|
+
# ExcelAutomation.query_cell returns {'Value', 'Formula', 'VisibleText'}
|
|
24
|
+
# We normalized keys to lowercase for MCP consistency if preferred, but keeping explicit mapping is safer.
|
|
25
|
+
data = automation.query_cell(sheet, cell)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"sheet": sheet,
|
|
29
|
+
"cell": cell,
|
|
30
|
+
"value": data.get("Value"),
|
|
31
|
+
"formula": data.get("Formula"),
|
|
32
|
+
"visible_text": data.get("VisibleText"),
|
|
33
|
+
"entry_type": str(type(data.get("Value")))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def read_range(
|
|
37
|
+
workbook_id: str,
|
|
38
|
+
sheet: str,
|
|
39
|
+
range_a1: Optional[str] = None,
|
|
40
|
+
include_formulas: bool = False,
|
|
41
|
+
max_rows: int = 1000,
|
|
42
|
+
max_cols: int = 100
|
|
43
|
+
) -> dict:
|
|
44
|
+
automation = session.get_workbook(workbook_id)
|
|
45
|
+
if not automation:
|
|
46
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
47
|
+
|
|
48
|
+
# If range_a1 is None, ExcelAutomation defaults to used_range
|
|
49
|
+
# We fetch it as a dataframe
|
|
50
|
+
df = automation.get_range_as_dataframe(sheet, range_a1)
|
|
51
|
+
|
|
52
|
+
# Check size limits
|
|
53
|
+
row_count, col_count = df.shape
|
|
54
|
+
total_cells = row_count * col_count
|
|
55
|
+
|
|
56
|
+
truncated = False
|
|
57
|
+
warnings = []
|
|
58
|
+
|
|
59
|
+
if total_cells > MAX_CELLS_READ:
|
|
60
|
+
warnings.append(f"Range exceeds global max cell limit ({MAX_CELLS_READ}). Truncating.")
|
|
61
|
+
truncated = True
|
|
62
|
+
|
|
63
|
+
# Also check per-request limits
|
|
64
|
+
if row_count > max_rows:
|
|
65
|
+
warnings.append(f"Row count ({row_count}) exceeds request limit ({max_rows}). Truncating rows.")
|
|
66
|
+
df = df.head(max_rows)
|
|
67
|
+
truncated = True
|
|
68
|
+
|
|
69
|
+
if col_count > max_cols:
|
|
70
|
+
warnings.append(f"Column count ({col_count}) exceeds request limit ({max_cols}). Truncating cols.")
|
|
71
|
+
df = df.iloc[:, :max_cols]
|
|
72
|
+
truncated = True
|
|
73
|
+
|
|
74
|
+
# Convert to 2D list (values)
|
|
75
|
+
# Note: ExcelAutomation.get_range_as_dataframe adds a 'RowNumber' column at index 0.
|
|
76
|
+
# We should probably exclude that if we want raw data, or keep it if useful.
|
|
77
|
+
# The plan asked for "values_2d". Let's keep it clean and remove the artificial 'RowNumber' if present.
|
|
78
|
+
|
|
79
|
+
if "RowNumber" in df.columns:
|
|
80
|
+
# Keep RowNumber might be useful for context, but usually 'read_range' expects the raw grid.
|
|
81
|
+
# Let's drop it to match the requested matrix shape of the range.
|
|
82
|
+
df = df.drop(columns=["RowNumber"])
|
|
83
|
+
|
|
84
|
+
# Handling NaNs: replace with None or "" for JSON serialization
|
|
85
|
+
df = df.where(pd.notnull(df), None)
|
|
86
|
+
|
|
87
|
+
values = df.values.tolist()
|
|
88
|
+
headers = list(df.columns)
|
|
89
|
+
|
|
90
|
+
# If include_formulas is True, we can't easily get them from the DF alone
|
|
91
|
+
# because xlwings/pandas fetch values.
|
|
92
|
+
# ExcelAutomation doesn't have a bulk "get formulas" for a range yet.
|
|
93
|
+
# For MVP, we will warn if requested but not supported efficiently,
|
|
94
|
+
# or loop (expensive). Plan says "formulas?".
|
|
95
|
+
# Let's defer formula-in-range for now or implement a specific bulk fetch in ExcelAutomation later.
|
|
96
|
+
if include_formulas:
|
|
97
|
+
warnings.append("include_formulas=True is not yet optimized/supported for bulk ranges. Returning values only.")
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"range_address": range_a1 or "UsedRange",
|
|
101
|
+
"shape": [len(values), len(headers)],
|
|
102
|
+
"headers": headers,
|
|
103
|
+
"values": values,
|
|
104
|
+
"truncated": truncated,
|
|
105
|
+
"warnings": warnings
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def read_sheet_preview(workbook_id: str, sheet: str, rows: int = 50, cols: int = 20) -> dict:
|
|
109
|
+
return read_range(workbook_id, sheet, range_a1=None, max_rows=rows, max_cols=cols)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
|
|
2
|
+
import re
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ..sessions import session
|
|
6
|
+
|
|
7
|
+
def _match(content: str, query: str, mode: str) -> bool:
|
|
8
|
+
content_str = str(content)
|
|
9
|
+
if mode == "exact":
|
|
10
|
+
return content_str == query
|
|
11
|
+
elif mode == "regex":
|
|
12
|
+
return bool(re.search(query, content_str))
|
|
13
|
+
else: # contains (default)
|
|
14
|
+
return query in content_str
|
|
15
|
+
|
|
16
|
+
def search(
|
|
17
|
+
workbook_id: str,
|
|
18
|
+
query: str,
|
|
19
|
+
sheet: Optional[str] = None,
|
|
20
|
+
scope: str = "both", # values, formulas, both
|
|
21
|
+
match_mode: str = "contains", # contains, exact, regex
|
|
22
|
+
max_hits: int = 200
|
|
23
|
+
) -> dict:
|
|
24
|
+
automation = session.get_workbook(workbook_id)
|
|
25
|
+
if not automation:
|
|
26
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
27
|
+
|
|
28
|
+
sheets_to_search = [sheet] if sheet else automation.list_sheets()
|
|
29
|
+
|
|
30
|
+
hits = []
|
|
31
|
+
truncated = False
|
|
32
|
+
|
|
33
|
+
for sheet_name in sheets_to_search:
|
|
34
|
+
if len(hits) >= max_hits:
|
|
35
|
+
truncated = True
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
# 1. Get entire sheet data (values)
|
|
39
|
+
# Using used_range df for efficiency
|
|
40
|
+
# Note: This is read-heavy. If sheet is massive, we might hit limits.
|
|
41
|
+
# But we must read to search unless we trust Excel's .Find (which is finicky via COM automation sometimes).
|
|
42
|
+
# We will iterate the dataframe locally which is fast for <100k cells.
|
|
43
|
+
|
|
44
|
+
# We use read_range logic (from current module, or direct automation call)
|
|
45
|
+
# We need raw values.
|
|
46
|
+
df = automation.get_range_as_dataframe(sheet_name)
|
|
47
|
+
|
|
48
|
+
# 2. Iterate and match
|
|
49
|
+
# df index is just row number 0..N, cols are 'I', 'J'...
|
|
50
|
+
# remove RowNumber col if present to avoid searching it
|
|
51
|
+
if "RowNumber" in df.columns:
|
|
52
|
+
# map row index to actual excel row number later if needed
|
|
53
|
+
# For now, let's keep RowNumber to help reconstruct address
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
for r_idx, row in df.iterrows():
|
|
57
|
+
if len(hits) >= max_hits:
|
|
58
|
+
truncated = True
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
actual_row = int(row["RowNumber"]) if "RowNumber" in row else r_idx + 1
|
|
62
|
+
|
|
63
|
+
for col_name, value in row.items():
|
|
64
|
+
if col_name == "RowNumber": continue
|
|
65
|
+
|
|
66
|
+
# Check VALUE
|
|
67
|
+
if scope in ["values", "both"] and value is not None:
|
|
68
|
+
if _match(str(value), query, match_mode):
|
|
69
|
+
hits.append({
|
|
70
|
+
"sheet": sheet_name,
|
|
71
|
+
"cell": f"{col_name}{actual_row}",
|
|
72
|
+
"value": value,
|
|
73
|
+
"match_type": "value"
|
|
74
|
+
})
|
|
75
|
+
if len(hits) >= max_hits: break
|
|
76
|
+
|
|
77
|
+
# Check FORMULA
|
|
78
|
+
# This is expensive if we do it per cell via COM.
|
|
79
|
+
# Optimization: Only check formula if requested.
|
|
80
|
+
# Current ExcelAutomation doesn't bulk read formulas easily into DF.
|
|
81
|
+
# Only check if scope requires it.
|
|
82
|
+
if scope in ["formulas", "both"]:
|
|
83
|
+
# If we matched value already and don't care to double-report, skip?
|
|
84
|
+
# Plan says scope="both". A cell might match on value but not formula or vice versa.
|
|
85
|
+
|
|
86
|
+
# COM call per cell is too slow for whole sheet search.
|
|
87
|
+
# We should rely on Excel's Find functionality or accept that 'formula' search is slow/limited.
|
|
88
|
+
# OR, we only support formula search if explicit or simple.
|
|
89
|
+
|
|
90
|
+
# Value search remains the supported bulk path until formula
|
|
91
|
+
# matrices can be read efficiently.
|
|
92
|
+
# If user really wants formula search, we might iterate only used range.
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
if len(hits) >= max_hits: break
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"query": query,
|
|
99
|
+
"hits": hits,
|
|
100
|
+
"truncated": truncated
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
|
|
2
|
+
from ..config import DEFAULT_MODE
|
|
3
|
+
from ..excel import ExcelAutomation
|
|
4
|
+
from ..safety import validate_path
|
|
5
|
+
from ..sessions import session
|
|
6
|
+
|
|
7
|
+
def open_workbook(path: str, mode: str = DEFAULT_MODE) -> dict:
|
|
8
|
+
"""
|
|
9
|
+
Opens a workbook and returns its ID and metadata.
|
|
10
|
+
mode: 'ro' (read-only) or 'rw' (read-write)
|
|
11
|
+
"""
|
|
12
|
+
# 1. Validate path
|
|
13
|
+
abs_path = validate_path(path)
|
|
14
|
+
|
|
15
|
+
# 2. Open workbook
|
|
16
|
+
# Note: Xlwings doesn't strictly support 'ro' at open() level easily without API flags,
|
|
17
|
+
# but we will enforce it at the Write tool level.
|
|
18
|
+
# We pass the validated absolute path string.
|
|
19
|
+
automation = ExcelAutomation(file_path=str(abs_path))
|
|
20
|
+
|
|
21
|
+
# 3. Store in session
|
|
22
|
+
wb_id = session.add_workbook(automation)
|
|
23
|
+
|
|
24
|
+
# 4. Gather metadata
|
|
25
|
+
sheets = automation.list_sheets()
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"workbook_id": wb_id,
|
|
29
|
+
"filename": abs_path.name,
|
|
30
|
+
"sheets": sheets,
|
|
31
|
+
"mode": mode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def close_workbook(workbook_id: str) -> dict:
|
|
35
|
+
automation = session.get_workbook(workbook_id)
|
|
36
|
+
if not automation:
|
|
37
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
38
|
+
|
|
39
|
+
# Close without quitting app to support multiple workbooks
|
|
40
|
+
automation.close(quit_app=False)
|
|
41
|
+
session.remove_workbook(workbook_id)
|
|
42
|
+
|
|
43
|
+
return {"status": "closed", "workbook_id": workbook_id}
|
|
44
|
+
|
|
45
|
+
def save_workbook(workbook_id: str) -> dict:
|
|
46
|
+
automation = session.get_workbook(workbook_id)
|
|
47
|
+
if not automation:
|
|
48
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
49
|
+
|
|
50
|
+
automation.save()
|
|
51
|
+
return {"status": "saved", "workbook_id": workbook_id}
|
|
52
|
+
|
|
53
|
+
def save_as_workbook(workbook_id: str, output_path: str) -> dict:
|
|
54
|
+
# 1. Validate output path
|
|
55
|
+
abs_path = validate_path(output_path, allow_write=True)
|
|
56
|
+
|
|
57
|
+
automation = session.get_workbook(workbook_id)
|
|
58
|
+
if not automation:
|
|
59
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
60
|
+
|
|
61
|
+
automation.save(str(abs_path))
|
|
62
|
+
|
|
63
|
+
return {"status": "saved_as", "path": str(abs_path), "workbook_id": workbook_id}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Any, List
|
|
3
|
+
from ..sessions import session
|
|
4
|
+
from ..config import MAX_CELLS_WRITE
|
|
5
|
+
from ..audit import log_write
|
|
6
|
+
|
|
7
|
+
def change_cell_value(workbook_id: str, sheet: str, cell: str, value: Any) -> dict:
|
|
8
|
+
automation = session.get_workbook(workbook_id)
|
|
9
|
+
if not automation:
|
|
10
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
11
|
+
|
|
12
|
+
automation.write_cell(sheet, cell, value)
|
|
13
|
+
|
|
14
|
+
# Audit logic
|
|
15
|
+
log_write("change_cell_value", workbook_id, {
|
|
16
|
+
"sheet": sheet,
|
|
17
|
+
"cell": cell,
|
|
18
|
+
"value_preview": str(value)[:50]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return {"status": "ok", "workbook_id": workbook_id}
|
|
22
|
+
|
|
23
|
+
def batch_update_cells(workbook_id: str, updates: List[dict]) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
updates: list of dicts with keys: sheet, cell, value
|
|
26
|
+
"""
|
|
27
|
+
automation = session.get_workbook(workbook_id)
|
|
28
|
+
if not automation:
|
|
29
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
30
|
+
|
|
31
|
+
if len(updates) > MAX_CELLS_WRITE:
|
|
32
|
+
raise ValueError(f"Batch update exceeds limit of {MAX_CELLS_WRITE} cells")
|
|
33
|
+
|
|
34
|
+
count = 0
|
|
35
|
+
# Xlwings/ExcelAutomation doesn't have a native 'batch disjoint write' yet
|
|
36
|
+
# so we iterate. Ideally, we optimize contiguous ranges later.
|
|
37
|
+
# But this is still better than 100 separate tool calls over MCP.
|
|
38
|
+
|
|
39
|
+
# Group by sheet to minimize sheet switching overhead if any (xlwings handles this well though)
|
|
40
|
+
for update in updates:
|
|
41
|
+
sheet = update.get("sheet")
|
|
42
|
+
cell = update.get("cell")
|
|
43
|
+
value = update.get("value")
|
|
44
|
+
|
|
45
|
+
# We allow "formula" key as an alias for value if client separates them,
|
|
46
|
+
# but ExcelAutomation.write_cell handles both via .value assignment normally.
|
|
47
|
+
# If specific formula assignment is needed, we treat it same as value.
|
|
48
|
+
if "formula" in update and value is None:
|
|
49
|
+
value = update["formula"]
|
|
50
|
+
|
|
51
|
+
if sheet and cell:
|
|
52
|
+
automation.write_cell(sheet, cell, value)
|
|
53
|
+
count += 1
|
|
54
|
+
|
|
55
|
+
log_write("batch_update_cells", workbook_id, {
|
|
56
|
+
"count": count
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return {"status": "ok", "updated_count": count}
|
|
60
|
+
|
|
61
|
+
def write_range(
|
|
62
|
+
workbook_id: str,
|
|
63
|
+
sheet: str,
|
|
64
|
+
start_cell: str,
|
|
65
|
+
values: List[List[Any]]
|
|
66
|
+
) -> dict:
|
|
67
|
+
automation = session.get_workbook(workbook_id)
|
|
68
|
+
if not automation:
|
|
69
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
70
|
+
|
|
71
|
+
# Check dimensions
|
|
72
|
+
rows = len(values)
|
|
73
|
+
cols = len(values[0]) if rows > 0 else 0
|
|
74
|
+
total_cells = rows * cols
|
|
75
|
+
|
|
76
|
+
if total_cells > MAX_CELLS_WRITE:
|
|
77
|
+
raise ValueError(f"Write range exceeds limit of {MAX_CELLS_WRITE} cells ({total_cells} requested)")
|
|
78
|
+
|
|
79
|
+
ws = automation.wb.sheets[sheet]
|
|
80
|
+
ws.range(start_cell).value = values
|
|
81
|
+
|
|
82
|
+
# Calculate end range for return info
|
|
83
|
+
# (Simplified assumption: contiguous block)
|
|
84
|
+
# xlwings handles the size automatically.
|
|
85
|
+
|
|
86
|
+
log_write("write_range", workbook_id, {
|
|
87
|
+
"sheet": sheet,
|
|
88
|
+
"start_cell": start_cell,
|
|
89
|
+
"rows": rows,
|
|
90
|
+
"cols": cols
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"status": "ok",
|
|
95
|
+
"written_cells": total_cells,
|
|
96
|
+
"shape": [rows, cols]
|
|
97
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Internal xlwings backend used by the ExcelTamer MCP engines."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import xlwings as xw
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExcelAutomation:
|
|
13
|
+
"""Own an Excel application/workbook pair for one MCP session entry."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, file_path: str | None = None):
|
|
16
|
+
self.app = xw.apps.active if xw.apps else xw.App(visible=True)
|
|
17
|
+
self.wb = (
|
|
18
|
+
self.app.books.open(file_path)
|
|
19
|
+
if file_path
|
|
20
|
+
else self.app.books.active if self.app.books else self.app.books.add()
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def save(self, file_path: str | None = None) -> None:
|
|
24
|
+
if file_path:
|
|
25
|
+
self.wb.save(file_path)
|
|
26
|
+
else:
|
|
27
|
+
self.wb.save()
|
|
28
|
+
|
|
29
|
+
def close(self, quit_app: bool = True) -> None:
|
|
30
|
+
self.wb.close()
|
|
31
|
+
if quit_app:
|
|
32
|
+
self.app.quit()
|
|
33
|
+
|
|
34
|
+
def list_sheets(self) -> list[str]:
|
|
35
|
+
return [sheet.name for sheet in self.wb.sheets]
|
|
36
|
+
|
|
37
|
+
def query_cell(self, sheet_name: str, cell: str) -> dict[str, Any]:
|
|
38
|
+
"""Return the value, formula, and rendered text for one cell."""
|
|
39
|
+
target = self.wb.sheets[sheet_name].range(cell)
|
|
40
|
+
return {
|
|
41
|
+
"Value": target.value,
|
|
42
|
+
"Formula": target.formula,
|
|
43
|
+
"VisibleText": target.api.Text,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def get_range_as_dataframe(
|
|
47
|
+
self, sheet_name: str, cell_range: str | None = None
|
|
48
|
+
) -> pd.DataFrame:
|
|
49
|
+
"""Read a range with Excel column letters and source row numbers."""
|
|
50
|
+
logger.debug(
|
|
51
|
+
"Getting range as DataFrame for sheet=%s range=%s",
|
|
52
|
+
sheet_name,
|
|
53
|
+
cell_range,
|
|
54
|
+
)
|
|
55
|
+
sheet = self.wb.sheets[sheet_name]
|
|
56
|
+
if not cell_range or not cell_range.strip():
|
|
57
|
+
cell_range = sheet.used_range.address
|
|
58
|
+
return self._range_to_dataframe(sheet, sheet.range(cell_range))
|
|
59
|
+
|
|
60
|
+
def _range_to_dataframe(
|
|
61
|
+
self, sheet: xw.Sheet, cell_range: xw.Range
|
|
62
|
+
) -> pd.DataFrame:
|
|
63
|
+
data = cell_range.value
|
|
64
|
+
if data is None:
|
|
65
|
+
return pd.DataFrame()
|
|
66
|
+
|
|
67
|
+
row_count = cell_range.rows.count
|
|
68
|
+
col_count = cell_range.columns.count
|
|
69
|
+
if not isinstance(data, list):
|
|
70
|
+
data = [[data]]
|
|
71
|
+
elif row_count == 1 and (not data or not isinstance(data[0], list)):
|
|
72
|
+
data = [data]
|
|
73
|
+
elif col_count == 1 and data and not isinstance(data[0], list):
|
|
74
|
+
data = [[value] for value in data]
|
|
75
|
+
|
|
76
|
+
start_row = cell_range.row
|
|
77
|
+
start_col = cell_range.column
|
|
78
|
+
columns = []
|
|
79
|
+
for offset in range(col_count):
|
|
80
|
+
address = sheet.range((start_row, start_col + offset)).address
|
|
81
|
+
columns.append("".join(char for char in address if char.isalpha()))
|
|
82
|
+
|
|
83
|
+
frame = pd.DataFrame(data, columns=columns)
|
|
84
|
+
frame.insert(0, "RowNumber", range(start_row, start_row + row_count))
|
|
85
|
+
return frame
|
|
86
|
+
|
|
87
|
+
def write_cell(self, sheet_name: str, cell: str, value: Any) -> None:
|
|
88
|
+
self.wb.sheets[sheet_name].range(cell).value = value
|
|
89
|
+
|
|
90
|
+
def get_structure(self) -> list[dict[str, Any]]:
|
|
91
|
+
"""Return sheet dimensions, used ranges, and named ranges."""
|
|
92
|
+
structure = []
|
|
93
|
+
for sheet in self.wb.sheets:
|
|
94
|
+
used_range = sheet.used_range
|
|
95
|
+
structure.append(
|
|
96
|
+
{
|
|
97
|
+
"Sheet Name": sheet.name,
|
|
98
|
+
"Rows": used_range.rows.count,
|
|
99
|
+
"Columns": used_range.columns.count,
|
|
100
|
+
"Range": used_range.address,
|
|
101
|
+
"Named Ranges": [
|
|
102
|
+
{
|
|
103
|
+
"Name": name.name,
|
|
104
|
+
"Refers To": name.refers_to_range.address,
|
|
105
|
+
}
|
|
106
|
+
for name in sheet.names
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return structure
|