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.
Files changed (32) hide show
  1. exceltamer-0.2.0/ExcelTamer/__init__.py +1 -0
  2. exceltamer-0.2.0/ExcelTamer/mcp/__init__.py +0 -0
  3. exceltamer-0.2.0/ExcelTamer/mcp/audit.py +37 -0
  4. exceltamer-0.2.0/ExcelTamer/mcp/config.py +25 -0
  5. exceltamer-0.2.0/ExcelTamer/mcp/engine/__init__.py +0 -0
  6. exceltamer-0.2.0/ExcelTamer/mcp/engine/diff.py +131 -0
  7. exceltamer-0.2.0/ExcelTamer/mcp/engine/read.py +109 -0
  8. exceltamer-0.2.0/ExcelTamer/mcp/engine/search.py +101 -0
  9. exceltamer-0.2.0/ExcelTamer/mcp/engine/workbook.py +63 -0
  10. exceltamer-0.2.0/ExcelTamer/mcp/engine/write.py +97 -0
  11. exceltamer-0.2.0/ExcelTamer/mcp/excel.py +110 -0
  12. exceltamer-0.2.0/ExcelTamer/mcp/main.py +18 -0
  13. exceltamer-0.2.0/ExcelTamer/mcp/prompts/README.md +0 -0
  14. exceltamer-0.2.0/ExcelTamer/mcp/prompts/financial_metric_extract.md +23 -0
  15. exceltamer-0.2.0/ExcelTamer/mcp/prompts/safe_edit.md +26 -0
  16. exceltamer-0.2.0/ExcelTamer/mcp/safety.py +39 -0
  17. exceltamer-0.2.0/ExcelTamer/mcp/schemas/__init__.py +0 -0
  18. exceltamer-0.2.0/ExcelTamer/mcp/server.py +541 -0
  19. exceltamer-0.2.0/ExcelTamer/mcp/sessions.py +35 -0
  20. exceltamer-0.2.0/ExcelTamer.egg-info/PKG-INFO +170 -0
  21. exceltamer-0.2.0/ExcelTamer.egg-info/SOURCES.txt +30 -0
  22. exceltamer-0.2.0/ExcelTamer.egg-info/dependency_links.txt +1 -0
  23. exceltamer-0.2.0/ExcelTamer.egg-info/entry_points.txt +2 -0
  24. exceltamer-0.2.0/ExcelTamer.egg-info/requires.txt +3 -0
  25. exceltamer-0.2.0/ExcelTamer.egg-info/top_level.txt +1 -0
  26. exceltamer-0.2.0/LICENSE +674 -0
  27. exceltamer-0.2.0/MANIFEST.in +3 -0
  28. exceltamer-0.2.0/PKG-INFO +170 -0
  29. exceltamer-0.2.0/README.md +146 -0
  30. exceltamer-0.2.0/pyproject.toml +45 -0
  31. exceltamer-0.2.0/setup.cfg +4 -0
  32. 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