ExcelTamer 0.2.0__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.
ExcelTamer/mcp/main.py ADDED
@@ -0,0 +1,18 @@
1
+
2
+ import asyncio
3
+ import sys
4
+ import argparse
5
+ from .server import run
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(description="ExcelTamer MCP Server")
9
+ parser.add_argument("--port", type=int, help="Port to run the SSE server on (default: stdio mode)")
10
+ args = parser.parse_args()
11
+
12
+ try:
13
+ asyncio.run(run(port=args.port))
14
+ except KeyboardInterrupt:
15
+ sys.exit(0)
16
+
17
+ if __name__ == "__main__":
18
+ main()
File without changes
@@ -0,0 +1,23 @@
1
+
2
+ You are an expert financial analyst. Your goal is to extract a specific financial metric (e.g., "Net Income", "Revenue") from an Excel workbook reliably.
3
+
4
+ Follow these steps:
5
+
6
+ 1. **Locate Data**:
7
+ - Open the workbook.
8
+ - Use `excel.search` to find the metric name (e.g., "Net Income") in the workbook.
9
+ - Note the sheet name and row number.
10
+
11
+ 2. **Identify Time Axis**:
12
+ - Look for date headers (years like "2023", "2024" or quarters "Q1", "Q2") in the rows above the metric or columns to the left.
13
+ - Use `excel.read_sheet_preview` or `excel.read_range` around the found metric cell to understand the table layout.
14
+
15
+ 3. **Extract Series**:
16
+ - Once you identified the metric row and the time columns, read the specific intersection cells using `excel.query_cell` or `excel.read_range`.
17
+
18
+ 4. **Validate**:
19
+ - Ensure the extracted values are numbers. If they are formulas, note that.
20
+
21
+ 5. **Report**:
22
+ - Present the extracted time-series data clearly.
23
+ - Close the workbook.
@@ -0,0 +1,26 @@
1
+
2
+ You are tasked with safely editing an Excel workbook using the `ExcelTamer` tools.
3
+ Follow this workflow strictly to ensure data integrity:
4
+
5
+ 1. **Open & Inspect**:
6
+ - Open the workbook (`excel.open_workbook`).
7
+ - Read the structure (`excel.get_structure`) and preview relevant sheets (`excel.read_sheet_preview`).
8
+
9
+ 2. **Plan**:
10
+ - Identify the specific cells or ranges that need modification.
11
+ - If searching for data, use `excel.search`.
12
+
13
+ 3. **Checkpoint (Critical)**:
14
+ - Before making ANY changes, create a checkpoint (`excel.checkpoint_create` with name "pre_edit").
15
+
16
+ 4. **Edit**:
17
+ - Apply changes using `excel.change_cell_value` or `excel.batch_update_cells` or `excel.write_range`.
18
+
19
+ 5. **Verify**:
20
+ - Read back the changed cells (`excel.read_range` or `excel.query_cell`) to confirm they match expectations.
21
+ - Check the operation history (`excel.preview_diff`).
22
+
23
+ 6. **Finalize**:
24
+ - If successful, save the workbook (`excel.save` or `excel.save_as`).
25
+ - If something went wrong, rollback immediately (`excel.checkpoint_rollback` name="pre_edit").
26
+ - Close the workbook (`excel.close`).
@@ -0,0 +1,39 @@
1
+
2
+ import os
3
+ from pathlib import Path
4
+ from .config import ALLOWED_ROOTS
5
+
6
+ class SecurityError(Exception):
7
+ pass
8
+
9
+ def validate_path(request_path: str, allow_write: bool = False) -> Path:
10
+ """
11
+ Validates that the given path is within ALLOWED_ROOTS.
12
+ Resolves symlinks and checks for traversal.
13
+ """
14
+ try:
15
+ # Resolve user path to absolute
16
+ abs_path = Path(request_path).resolve()
17
+
18
+ # Check if it matches any allowed root
19
+ is_allowed = False
20
+ for root in ALLOWED_ROOTS:
21
+ allowed_root = Path(root).resolve()
22
+ # method 1: check if allowed_root is a parent of abs_path
23
+ # calculate relative path; if it starts with '..', it's outside
24
+ try:
25
+ abs_path.relative_to(allowed_root)
26
+ is_allowed = True
27
+ break
28
+ except ValueError:
29
+ continue
30
+
31
+ if not is_allowed:
32
+ raise SecurityError(f"Path '{request_path}' is not within allowed roots: {ALLOWED_ROOTS}")
33
+
34
+ return abs_path
35
+
36
+ except Exception as e:
37
+ if isinstance(e, SecurityError):
38
+ raise
39
+ raise SecurityError(f"Invalid path: {e}")
File without changes
@@ -0,0 +1,541 @@
1
+
2
+ import asyncio
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from mcp.server import Server
7
+ from mcp.types import (
8
+ Tool,
9
+ TextContent,
10
+ ImageContent,
11
+ EmbeddedResource
12
+ )
13
+ import mcp.types as types
14
+
15
+ from .engine import workbook as workbook_engine
16
+ from .engine import read as read_engine
17
+ from .engine import write as write_engine
18
+ from .engine import search as search_engine
19
+ from .engine import diff as diff_engine
20
+ from .sessions import session
21
+ from mcp.types import Resource, Prompt, PromptMessage, PromptArgument
22
+
23
+ # Configure logging (stderr so it doesn't break json-rpc on stdout)
24
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
25
+ logger = logging.getLogger("ExcelTamerMCP")
26
+
27
+ server = Server("exceltamer-mcp")
28
+ PROMPT_DIR = Path(__file__).with_name("prompts")
29
+
30
+ @server.list_tools()
31
+ async def handle_list_tools() -> list[Tool]:
32
+ return [
33
+ Tool(
34
+ name="excel.open_workbook",
35
+ description="Open an Excel workbook and get a workbook_id. required for other operations.",
36
+ inputSchema={
37
+ "type": "object",
38
+ "properties": {
39
+ "path": {"type": "string", "description": "Absolute path to the Excel file"},
40
+ "mode": {"type": "string", "enum": ["ro", "rw"], "default": "ro", "description": "Open mode: ro=read-only, rw=read-write"}
41
+ },
42
+ "required": ["path"]
43
+ }
44
+ ),
45
+ Tool(
46
+ name="excel.close",
47
+ description="Close an open workbook by ID.",
48
+ inputSchema={
49
+ "type": "object",
50
+ "properties": {
51
+ "workbook_id": {"type": "string"}
52
+ },
53
+ "required": ["workbook_id"]
54
+ }
55
+ ),
56
+ Tool(
57
+ name="excel.save",
58
+ description="Save the workbook (overwriting file).",
59
+ inputSchema={
60
+ "type": "object",
61
+ "properties": {
62
+ "workbook_id": {"type": "string"}
63
+ },
64
+ "required": ["workbook_id"]
65
+ }
66
+ ),
67
+ Tool(
68
+ name="excel.save_as",
69
+ description="Save the workbook to a new path.",
70
+ inputSchema={
71
+ "type": "object",
72
+ "properties": {
73
+ "workbook_id": {"type": "string"},
74
+ "output_path": {"type": "string"}
75
+ },
76
+ "required": ["workbook_id", "output_path"]
77
+ }
78
+ ),
79
+ Tool(
80
+ name="excel.get_structure",
81
+ description="Get structure of the workbook (sheets, named ranges).",
82
+ inputSchema={
83
+ "type": "object",
84
+ "properties": {
85
+ "workbook_id": {"type": "string"}
86
+ },
87
+ "required": ["workbook_id"]
88
+ }
89
+ ),
90
+ Tool(
91
+ name="excel.query_cell",
92
+ description="Get value, formula, and text of a specific cell.",
93
+ inputSchema={
94
+ "type": "object",
95
+ "properties": {
96
+ "workbook_id": {"type": "string"},
97
+ "sheet": {"type": "string"},
98
+ "cell": {"type": "string"}
99
+ },
100
+ "required": ["workbook_id", "sheet", "cell"]
101
+ }
102
+ ),
103
+ Tool(
104
+ name="excel.read_range",
105
+ description="Read a range of cells as a 2D array.",
106
+ inputSchema={
107
+ "type": "object",
108
+ "properties": {
109
+ "workbook_id": {"type": "string"},
110
+ "sheet": {"type": "string"},
111
+ "range_a1": {"type": "string", "description": "Range address (e.g. A1:C10). If omitted, uses used range."},
112
+ "max_rows": {"type": "integer", "default": 1000},
113
+ "max_cols": {"type": "integer", "default": 100}
114
+ },
115
+ "required": ["workbook_id", "sheet"]
116
+ }
117
+ ),
118
+ Tool(
119
+ name="excel.read_sheet_preview",
120
+ description="Quick preview of a sheet's content (top-left).",
121
+ inputSchema={
122
+ "type": "object",
123
+ "properties": {
124
+ "workbook_id": {"type": "string"},
125
+ "sheet": {"type": "string"},
126
+ "rows": {"type": "integer", "default": 50},
127
+ "cols": {"type": "integer", "default": 20}
128
+ },
129
+ "required": ["workbook_id", "sheet"]
130
+ }
131
+ ),
132
+ Tool(
133
+ name="excel.change_cell_value",
134
+ description="Write a value/formula to a single cell.",
135
+ inputSchema={
136
+ "type": "object",
137
+ "properties": {
138
+ "workbook_id": {"type": "string"},
139
+ "sheet": {"type": "string"},
140
+ "cell": {"type": "string"},
141
+ "value": {"type": ["string", "number", "boolean", "null"]}
142
+ },
143
+ "required": ["workbook_id", "sheet", "cell", "value"]
144
+ }
145
+ ),
146
+ Tool(
147
+ name="excel.batch_update_cells",
148
+ description="Write multiple non-contiguous cells in one go.",
149
+ inputSchema={
150
+ "type": "object",
151
+ "properties": {
152
+ "workbook_id": {"type": "string"},
153
+ "updates": {
154
+ "type": "array",
155
+ "items": {
156
+ "type": "object",
157
+ "properties": {
158
+ "sheet": {"type": "string"},
159
+ "cell": {"type": "string"},
160
+ "value": {"type": ["string", "number", "boolean", "null"]}
161
+ },
162
+ "required": ["sheet", "cell", "value"]
163
+ }
164
+ }
165
+ },
166
+ "required": ["workbook_id", "updates"]
167
+ }
168
+ ),
169
+ Tool(
170
+ name="excel.write_range",
171
+ description="Write a 2D array of values starting at a specific cell.",
172
+ inputSchema={
173
+ "type": "object",
174
+ "properties": {
175
+ "workbook_id": {"type": "string"},
176
+ "sheet": {"type": "string"},
177
+ "start_cell": {"type": "string"},
178
+ "values": {
179
+ "type": "array",
180
+ "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}
181
+ }
182
+ },
183
+ "required": ["workbook_id", "sheet", "start_cell", "values"]
184
+ }
185
+ ),
186
+ Tool(
187
+ name="excel.search",
188
+ description="Search for a value across a sheet or the entire workbook.",
189
+ inputSchema={
190
+ "type": "object",
191
+ "properties": {
192
+ "workbook_id": {"type": "string"},
193
+ "query": {"type": "string"},
194
+ "sheet": {"type": "string", "description": "Optional. If omitted, searches all sheets."},
195
+ "scope": {"type": "string", "enum": ["values", "formulas", "both"], "default": "both"},
196
+ "match_mode": {"type": "string", "enum": ["contains", "exact", "regex"], "default": "contains"},
197
+ "max_hits": {"type": "integer", "default": 200}
198
+ },
199
+ "required": ["workbook_id", "query"]
200
+ }
201
+ ),
202
+ Tool(
203
+ name="excel.checkpoint_create",
204
+ description="Create a named checkpoint of the current workbook state.",
205
+ inputSchema={
206
+ "type": "object",
207
+ "properties": {
208
+ "workbook_id": {"type": "string"},
209
+ "name": {"type": "string"}
210
+ },
211
+ "required": ["workbook_id", "name"]
212
+ }
213
+ ),
214
+ Tool(
215
+ name="excel.checkpoint_rollback",
216
+ description="Rollback workbook to a named checkpoint (reloads file).",
217
+ inputSchema={
218
+ "type": "object",
219
+ "properties": {
220
+ "workbook_id": {"type": "string"},
221
+ "name": {"type": "string"}
222
+ },
223
+ "required": ["workbook_id", "name"]
224
+ }
225
+ ),
226
+ Tool(
227
+ name="excel.preview_diff",
228
+ description="Show recent changes/audit history for this workbook.",
229
+ inputSchema={
230
+ "type": "object",
231
+ "properties": {
232
+ "workbook_id": {"type": "string"},
233
+ "max_changes": {"type": "integer", "default": 20}
234
+ },
235
+ "required": ["workbook_id"]
236
+ }
237
+ )
238
+ ]
239
+
240
+ @server.list_resources()
241
+ async def handle_list_resources() -> list[Resource]:
242
+ # Resource: List of open workbooks
243
+ # URI: excel://workbooks
244
+ wb_list_resource = Resource(
245
+ uri="excel://workbooks",
246
+ name="Open Workbooks",
247
+ description="List of currently open workbook IDs and filenames",
248
+ mimeType="application/json"
249
+ )
250
+ return [wb_list_resource]
251
+
252
+ @server.read_resource()
253
+ async def handle_read_resource(uri: str) -> str | bytes:
254
+ if uri == "excel://workbooks":
255
+ workbooks = []
256
+ for wb_id, automation in session.open_workbooks.items():
257
+ workbooks.append({
258
+ "id": wb_id,
259
+ "name": automation.wb.name
260
+ })
261
+ return str(workbooks)
262
+
263
+ # Pattern: excel://workbooks/{id}/summary
264
+ # Simple manual parsing since we don't have pattern matching in this basic skeleton
265
+ import re
266
+ match = re.match(r"excel://workbooks/([^/]+)/summary", uri)
267
+ if match:
268
+ wb_id = match.group(1)
269
+ automation = session.get_workbook(wb_id)
270
+ if automation:
271
+ return str(read_engine.get_structure(wb_id))
272
+ else:
273
+ raise ValueError(f"Workbook {wb_id} not found")
274
+
275
+ raise ValueError(f"Resource not found: {uri}")
276
+
277
+ @server.list_prompts()
278
+ async def handle_list_prompts() -> list[Prompt]:
279
+ return [
280
+ Prompt(
281
+ name="safe-edit",
282
+ description="Workflow for safely editing an Excel file with checkpoints.",
283
+ arguments=[]
284
+ ),
285
+ Prompt(
286
+ name="financial-extract",
287
+ description="Workflow for extracting financial metrics.",
288
+ arguments=[]
289
+ )
290
+ ]
291
+
292
+ @server.get_prompt()
293
+ async def handle_get_prompt(name: str, arguments: dict | None) -> types.GetPromptResult:
294
+ if name == "safe-edit":
295
+ content = (PROMPT_DIR / "safe_edit.md").read_text(encoding="utf-8")
296
+ return types.GetPromptResult(
297
+ messages=[
298
+ PromptMessage(
299
+ role="user",
300
+ content=TextContent(type="text", text=content)
301
+ )
302
+ ]
303
+ )
304
+
305
+ elif name == "financial-extract":
306
+ content = (PROMPT_DIR / "financial_metric_extract.md").read_text(
307
+ encoding="utf-8"
308
+ )
309
+ return types.GetPromptResult(
310
+ messages=[
311
+ PromptMessage(
312
+ role="user",
313
+ content=TextContent(type="text", text=content)
314
+ )
315
+ ]
316
+ )
317
+
318
+ raise ValueError(f"Prompt not found: {name}")
319
+
320
+ @server.call_tool()
321
+ async def handle_call_tool(
322
+ name: str, arguments: dict | None
323
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
324
+ if not arguments:
325
+ arguments = {}
326
+
327
+ try:
328
+ if name == "excel.open_workbook":
329
+ path = arguments.get("path")
330
+ mode = arguments.get("mode", "ro")
331
+ if not path:
332
+ raise ValueError("path is required")
333
+ result = workbook_engine.open_workbook(path, mode)
334
+ return [TextContent(type="text", text=str(result))]
335
+
336
+ elif name == "excel.close":
337
+ workbook_id = arguments.get("workbook_id")
338
+ if not workbook_id:
339
+ raise ValueError("workbook_id is required")
340
+ result = workbook_engine.close_workbook(workbook_id)
341
+ return [TextContent(type="text", text=str(result))]
342
+
343
+ elif name == "excel.save":
344
+ workbook_id = arguments.get("workbook_id")
345
+ if not workbook_id:
346
+ raise ValueError("workbook_id is required")
347
+ result = workbook_engine.save_workbook(workbook_id)
348
+ return [TextContent(type="text", text=str(result))]
349
+
350
+ elif name == "excel.save_as":
351
+ workbook_id = arguments.get("workbook_id")
352
+ output_path = arguments.get("output_path")
353
+ if not workbook_id or not output_path:
354
+ raise ValueError("workbook_id and output_path are required")
355
+ result = workbook_engine.save_as_workbook(workbook_id, output_path)
356
+ return [TextContent(type="text", text=str(result))]
357
+
358
+ elif name == "excel.get_structure":
359
+ workbook_id = arguments.get("workbook_id")
360
+ if not workbook_id:
361
+ raise ValueError("workbook_id is required")
362
+ result = read_engine.get_structure(workbook_id)
363
+ return [TextContent(type="text", text=str(result))]
364
+
365
+ elif name == "excel.query_cell":
366
+ workbook_id = arguments.get("workbook_id")
367
+ sheet = arguments.get("sheet")
368
+ cell = arguments.get("cell")
369
+ if not workbook_id or not sheet or not cell:
370
+ raise ValueError("workbook_id, sheet, and cell are required")
371
+ result = read_engine.query_cell(workbook_id, sheet, cell)
372
+ return [TextContent(type="text", text=str(result))]
373
+
374
+ elif name == "excel.read_range":
375
+ workbook_id = arguments.get("workbook_id")
376
+ sheet = arguments.get("sheet")
377
+ range_a1 = arguments.get("range_a1")
378
+ max_rows = arguments.get("max_rows", 1000)
379
+ max_cols = arguments.get("max_cols", 100)
380
+
381
+ if not workbook_id or not sheet:
382
+ raise ValueError("workbook_id and sheet are required")
383
+
384
+ result = read_engine.read_range(
385
+ workbook_id,
386
+ sheet,
387
+ range_a1=range_a1,
388
+ max_rows=max_rows,
389
+ max_cols=max_cols
390
+ )
391
+ return [TextContent(type="text", text=str(result))]
392
+
393
+ elif name == "excel.read_sheet_preview":
394
+ workbook_id = arguments.get("workbook_id")
395
+ sheet = arguments.get("sheet")
396
+ rows = arguments.get("rows", 50)
397
+ cols = arguments.get("cols", 20)
398
+
399
+ if not workbook_id or not sheet:
400
+ raise ValueError("workbook_id and sheet are required")
401
+
402
+ result = read_engine.read_sheet_preview(workbook_id, sheet, rows=rows, cols=cols)
403
+ return [TextContent(type="text", text=str(result))]
404
+
405
+ elif name == "excel.change_cell_value":
406
+ workbook_id = arguments.get("workbook_id")
407
+ sheet = arguments.get("sheet")
408
+ cell = arguments.get("cell")
409
+ value = arguments.get("value")
410
+
411
+ if not workbook_id or not sheet or not cell:
412
+ raise ValueError("workbook_id, sheet, and cell are required")
413
+
414
+ result = write_engine.change_cell_value(workbook_id, sheet, cell, value)
415
+ return [TextContent(type="text", text=str(result))]
416
+
417
+ elif name == "excel.batch_update_cells":
418
+ workbook_id = arguments.get("workbook_id")
419
+ updates = arguments.get("updates")
420
+
421
+ if not workbook_id or not updates:
422
+ raise ValueError("workbook_id and updates are required")
423
+
424
+ result = write_engine.batch_update_cells(workbook_id, updates)
425
+ return [TextContent(type="text", text=str(result))]
426
+
427
+ elif name == "excel.write_range":
428
+ workbook_id = arguments.get("workbook_id")
429
+ sheet = arguments.get("sheet")
430
+ start_cell = arguments.get("start_cell")
431
+ values = arguments.get("values")
432
+
433
+ if not workbook_id or not sheet or not start_cell or values is None:
434
+ raise ValueError("workbook_id, sheet, start_cell, and values are required")
435
+
436
+ result = write_engine.write_range(workbook_id, sheet, start_cell, values)
437
+ return [TextContent(type="text", text=str(result))]
438
+
439
+ elif name == "excel.search":
440
+ workbook_id = arguments.get("workbook_id")
441
+ query = arguments.get("query")
442
+ sheet = arguments.get("sheet")
443
+ scope = arguments.get("scope", "both")
444
+ match_mode = arguments.get("match_mode", "contains")
445
+ max_hits = arguments.get("max_hits", 200)
446
+
447
+ if not workbook_id or not query:
448
+ raise ValueError("workbook_id and query are required")
449
+
450
+ result = search_engine.search(
451
+ workbook_id, query,
452
+ sheet=sheet,
453
+ scope=scope,
454
+ match_mode=match_mode,
455
+ max_hits=max_hits
456
+ )
457
+ return [TextContent(type="text", text=str(result))]
458
+
459
+ elif name == "excel.checkpoint_create":
460
+ workbook_id = arguments.get("workbook_id")
461
+ name = arguments.get("name")
462
+ if not workbook_id or not name:
463
+ raise ValueError("workbook_id and name required")
464
+ result = diff_engine.checkpoint_create(workbook_id, name)
465
+ return [TextContent(type="text", text=str(result))]
466
+
467
+ elif name == "excel.checkpoint_rollback":
468
+ workbook_id = arguments.get("workbook_id")
469
+ name = arguments.get("name")
470
+ if not workbook_id or not name:
471
+ raise ValueError("workbook_id and name required")
472
+ result = diff_engine.checkpoint_rollback(workbook_id, name)
473
+ return [TextContent(type="text", text=str(result))]
474
+
475
+ elif name == "excel.preview_diff":
476
+ workbook_id = arguments.get("workbook_id")
477
+ max_changes = arguments.get("max_changes", 20)
478
+ if not workbook_id:
479
+ raise ValueError("workbook_id required")
480
+ result = diff_engine.preview_diff(workbook_id, max_changes)
481
+ return [TextContent(type="text", text=str(result))]
482
+
483
+ else:
484
+ raise ValueError(f"Unknown tool: {name}")
485
+
486
+ except Exception as e:
487
+ logger.error(f"Error executing {name}: {e}")
488
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
489
+
490
+
491
+ async def run_stdio():
492
+ from mcp.server.stdio import stdio_server
493
+
494
+ async with stdio_server() as (read_stream, write_stream):
495
+ await server.run(
496
+ read_stream,
497
+ write_stream,
498
+ server.create_initialization_options()
499
+ )
500
+
501
+ async def run_sse(port: int):
502
+ from mcp.server.sse import SseServerTransport
503
+ from starlette.applications import Starlette
504
+ from starlette.routing import Route
505
+ import uvicorn
506
+
507
+ sse = SseServerTransport("/messages")
508
+
509
+ async def handle_sse(request):
510
+ async with sse.connect_sse(
511
+ request.scope,
512
+ request.receive,
513
+ request._send
514
+ ) as streams:
515
+ await server.run(
516
+ streams[0],
517
+ streams[1],
518
+ server.create_initialization_options()
519
+ )
520
+
521
+ async def handle_messages(request):
522
+ await sse.handle_post_message(request.scope, request.receive, request._send)
523
+
524
+ app = Starlette(
525
+ debug=True,
526
+ routes=[
527
+ Route("/sse", endpoint=handle_sse),
528
+ Route("/messages", endpoint=handle_messages, methods=["POST"])
529
+ ]
530
+ )
531
+
532
+ config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
533
+ server_instance = uvicorn.Server(config)
534
+ await server_instance.serve()
535
+
536
+ async def run(port: int | None = None):
537
+ if port is not None:
538
+ await run_sse(port)
539
+ else:
540
+ await run_stdio()
541
+
@@ -0,0 +1,35 @@
1
+
2
+ import uuid
3
+ from typing import Dict, Optional
4
+ from .excel import ExcelAutomation
5
+
6
+ class SessionState:
7
+ _instance = None
8
+
9
+ def __new__(cls):
10
+ if cls._instance is None:
11
+ cls._instance = super(SessionState, cls).__new__(cls)
12
+ cls._instance.open_workbooks = {}
13
+ return cls._instance
14
+
15
+ def __init__(self):
16
+ # type hints
17
+ self.open_workbooks: Dict[str, ExcelAutomation]
18
+
19
+ def add_workbook(self, automation: ExcelAutomation) -> str:
20
+ wb_id = str(uuid.uuid4())
21
+ self.open_workbooks[wb_id] = automation
22
+ return wb_id
23
+
24
+ def get_workbook(self, wb_id: str) -> Optional[ExcelAutomation]:
25
+ return self.open_workbooks.get(wb_id)
26
+
27
+ def remove_workbook(self, wb_id: str):
28
+ if wb_id in self.open_workbooks:
29
+ del self.open_workbooks[wb_id]
30
+
31
+ def clear(self):
32
+ self.open_workbooks.clear()
33
+
34
+ # Global session singleton
35
+ session = SessionState()