├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .python-version ├── LICENSE ├── README.md ├── TOOLS.md ├── assets └── logo.svg ├── pyproject.toml ├── src └── excel_mcp │ ├── __main__.py │ ├── calculations.py │ ├── cell_utils.py │ ├── chart.py │ ├── data.py │ ├── exceptions.py │ ├── formatting.py │ ├── pivot.py │ ├── server.py │ ├── sheet.py │ ├── validation.py │ └── workbook.py └── uv.lock /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: release 13 | url: https://pypi.org/project/excel-mcp-server 14 | permissions: 15 | id-token: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.x" 24 | 25 | - name: Install hatch dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install hatch 29 | 30 | - name: Build package 31 | run: hatch build 32 | 33 | - name: Publish to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build and Distribution 2 | __pycache__/ 3 | *.py[cod] 4 | build/ 5 | dist/ 6 | src/*.egg-info/ 7 | 8 | # Development Environment 9 | .venv/ 10 | .env 11 | 12 | # IDE 13 | .vscode/ 14 | .idea/ 15 | .cursor/ 16 | .cursorignore 17 | .cursorrules 18 | .specstory 19 | 20 | # Testing and Linting 21 | .coverage 22 | .pytest_cache/ 23 | .ruff_cache/ 24 | .mypy_cache/ 25 | htmlcov/ 26 | tests/ 27 | 28 | # Project Files 29 | extras/ 30 | .notes/ 31 | logs/ 32 | output/ 33 | *.xlsx 34 | *.xls 35 | *.log 36 | excel_files/ -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Haris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Excel MCP Server Logo 3 |

4 | 5 | [![PyPI version](https://img.shields.io/pypi/v/excel-mcp-server.svg)](https://pypi.org/project/excel-mcp-server/) 6 | [![PyPI downloads](https://img.shields.io/pypi/dm/excel-mcp-server.svg)](https://pypi.org/project/excel-mcp-server/) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | A Model Context Protocol (MCP) server that lets you manipulate Excel files without needing Microsoft Excel installed. Create, read, and modify Excel workbooks with your AI agent. 10 | 11 | ## Features 12 | 13 | - 📊 Create and modify Excel workbooks 14 | - 📝 Read and write data 15 | - 🎨 Apply formatting and styles 16 | - 📈 Create charts and visualizations 17 | - 📊 Generate pivot tables 18 | - 🔄 Manage worksheets and ranges 19 | - 🔌 Dual transport support: stdio and SSE 20 | 21 | ## Quick Start 22 | 23 | ### Prerequisites 24 | 25 | - Python 3.10 or higher 26 | 27 | ### Running the Server 28 | 29 | The server supports two transport modes: stdio and SSE. 30 | 31 | #### Using stdio transport 32 | 33 | Stdio transport is ideal for direct integration with tools like Cursor Desktop or local development, which can manipulate local files: 34 | 35 | ```bash 36 | uvx excel-mcp-server stdio 37 | ``` 38 | 39 | #### Using SSE transport 40 | 41 | SSE transport is perfect for remote connections, which manipulate remote files: 42 | 43 | ```bash 44 | uvx excel-mcp-server sse 45 | ``` 46 | 47 | ### Add to Cursor 48 | 49 | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=excel-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGV4Y2VsLW1jcC1zZXJ2ZXIgc3RkaW8ifQ%3D%3D) 50 | 51 | ## Using with AI Tools 52 | 53 | 1. Add this configuration to your client, choosing the appropriate transport method for your needs: 54 | 55 | **Stdio transport connection** (for local integration): 56 | ```json 57 | { 58 | "mcpServers": { 59 | "excel-stdio": { 60 | "command": "uvx", 61 | "args": ["excel-mcp-server", "stdio"] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | **SSE transport connection**: 68 | ```json 69 | { 70 | "mcpServers": { 71 | "excel": { 72 | "url": "http://localhost:8000/sse", 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | 2. The Excel tools will be available through your AI assistant. 79 | 80 | ## Environment Variables & File Path Handling 81 | 82 | ### SSE Transport 83 | 84 | When running the server with the **SSE protocol**, you **must set the `EXCEL_FILES_PATH` environment variable on the server side**. This variable tells the server where to read and write Excel files. 85 | - If not set, it defaults to `./excel_files`. 86 | 87 | You can also set the `FASTMCP_PORT` environment variable to control the port the server listens on (default is `8000` if not set). 88 | - Example (Windows PowerShell): 89 | ```powershell 90 | $env:EXCEL_FILES_PATH="E:\MyExcelFiles" 91 | $env:FASTMCP_PORT="8080" 92 | uvx excel-mcp-server sse 93 | ``` 94 | - Example (Linux/macOS): 95 | ```bash 96 | EXCEL_FILES_PATH=/path/to/excel_files FASTMCP_PORT=8080 uvx excel-mcp-server sse 97 | ``` 98 | 99 | ### Stdio Transport 100 | 101 | When using the **stdio protocol**, the file path is provided with each tool call, so you do **not** need to set `EXCEL_FILES_PATH` on the server. The server will use the path sent by the client for each operation. 102 | 103 | ## Available Tools 104 | 105 | The server provides a comprehensive set of Excel manipulation tools. See [TOOLS.md](TOOLS.md) for complete documentation of all available tools. 106 | 107 | ## Star History 108 | 109 | [![Star History Chart](https://api.star-history.com/svg?repos=haris-musa/excel-mcp-server&type=Date)](https://www.star-history.com/#haris-musa/excel-mcp-server&Date) 110 | 111 | ## License 112 | 113 | MIT License - see [LICENSE](LICENSE) for details. 114 | -------------------------------------------------------------------------------- /TOOLS.md: -------------------------------------------------------------------------------- 1 | # Excel MCP Server Tools 2 | 3 | This document provides detailed information about all available tools in the Excel MCP server. 4 | 5 | ## Workbook Operations 6 | 7 | ### create_workbook 8 | 9 | Creates a new Excel workbook. 10 | 11 | ```python 12 | create_workbook(filepath: str) -> str 13 | ``` 14 | 15 | - `filepath`: Path where to create workbook 16 | - Returns: Success message with created file path 17 | 18 | ### create_worksheet 19 | 20 | Creates a new worksheet in an existing workbook. 21 | 22 | ```python 23 | create_worksheet(filepath: str, sheet_name: str) -> str 24 | ``` 25 | 26 | - `filepath`: Path to Excel file 27 | - `sheet_name`: Name for the new worksheet 28 | - Returns: Success message 29 | 30 | ### get_workbook_metadata 31 | 32 | Get metadata about workbook including sheets and ranges. 33 | 34 | ```python 35 | get_workbook_metadata(filepath: str, include_ranges: bool = False) -> str 36 | ``` 37 | 38 | - `filepath`: Path to Excel file 39 | - `include_ranges`: Whether to include range information 40 | - Returns: String representation of workbook metadata 41 | 42 | ## Data Operations 43 | 44 | ### write_data_to_excel 45 | 46 | Write data to Excel worksheet. 47 | 48 | ```python 49 | write_data_to_excel( 50 | filepath: str, 51 | sheet_name: str, 52 | data: List[Dict], 53 | start_cell: str = "A1" 54 | ) -> str 55 | ``` 56 | 57 | - `filepath`: Path to Excel file 58 | - `sheet_name`: Target worksheet name 59 | - `data`: List of dictionaries containing data to write 60 | - `start_cell`: Starting cell (default: "A1") 61 | - Returns: Success message 62 | 63 | ### read_data_from_excel 64 | 65 | Read data from Excel worksheet. 66 | 67 | ```python 68 | read_data_from_excel( 69 | filepath: str, 70 | sheet_name: str, 71 | start_cell: str = "A1", 72 | end_cell: str = None, 73 | preview_only: bool = False 74 | ) -> str 75 | ``` 76 | 77 | - `filepath`: Path to Excel file 78 | - `sheet_name`: Source worksheet name 79 | - `start_cell`: Starting cell (default: "A1") 80 | - `end_cell`: Optional ending cell 81 | - `preview_only`: Whether to return only a preview 82 | - Returns: String representation of data 83 | 84 | ## Formatting Operations 85 | 86 | ### format_range 87 | 88 | Apply formatting to a range of cells. 89 | 90 | ```python 91 | format_range( 92 | filepath: str, 93 | sheet_name: str, 94 | start_cell: str, 95 | end_cell: str = None, 96 | bold: bool = False, 97 | italic: bool = False, 98 | underline: bool = False, 99 | font_size: int = None, 100 | font_color: str = None, 101 | bg_color: str = None, 102 | border_style: str = None, 103 | border_color: str = None, 104 | number_format: str = None, 105 | alignment: str = None, 106 | wrap_text: bool = False, 107 | merge_cells: bool = False, 108 | protection: Dict[str, Any] = None, 109 | conditional_format: Dict[str, Any] = None 110 | ) -> str 111 | ``` 112 | 113 | - `filepath`: Path to Excel file 114 | - `sheet_name`: Target worksheet name 115 | - `start_cell`: Starting cell of range 116 | - `end_cell`: Optional ending cell of range 117 | - Various formatting options (see parameters) 118 | - Returns: Success message 119 | 120 | ### merge_cells 121 | 122 | Merge a range of cells. 123 | 124 | ```python 125 | merge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str 126 | ``` 127 | 128 | - `filepath`: Path to Excel file 129 | - `sheet_name`: Target worksheet name 130 | - `start_cell`: Starting cell of range 131 | - `end_cell`: Ending cell of range 132 | - Returns: Success message 133 | 134 | ### unmerge_cells 135 | 136 | Unmerge a previously merged range of cells. 137 | 138 | ```python 139 | unmerge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str 140 | ``` 141 | 142 | - `filepath`: Path to Excel file 143 | - `sheet_name`: Target worksheet name 144 | - `start_cell`: Starting cell of range 145 | - `end_cell`: Ending cell of range 146 | - Returns: Success message 147 | 148 | ## Formula Operations 149 | 150 | ### apply_formula 151 | 152 | Apply Excel formula to cell. 153 | 154 | ```python 155 | apply_formula(filepath: str, sheet_name: str, cell: str, formula: str) -> str 156 | ``` 157 | 158 | - `filepath`: Path to Excel file 159 | - `sheet_name`: Target worksheet name 160 | - `cell`: Target cell reference 161 | - `formula`: Excel formula to apply 162 | - Returns: Success message 163 | 164 | ### validate_formula_syntax 165 | 166 | Validate Excel formula syntax without applying it. 167 | 168 | ```python 169 | validate_formula_syntax(filepath: str, sheet_name: str, cell: str, formula: str) -> str 170 | ``` 171 | 172 | - `filepath`: Path to Excel file 173 | - `sheet_name`: Target worksheet name 174 | - `cell`: Target cell reference 175 | - `formula`: Excel formula to validate 176 | - Returns: Validation result message 177 | 178 | ## Chart Operations 179 | 180 | ### create_chart 181 | 182 | Create chart in worksheet. 183 | 184 | ```python 185 | create_chart( 186 | filepath: str, 187 | sheet_name: str, 188 | data_range: str, 189 | chart_type: str, 190 | target_cell: str, 191 | title: str = "", 192 | x_axis: str = "", 193 | y_axis: str = "" 194 | ) -> str 195 | ``` 196 | 197 | - `filepath`: Path to Excel file 198 | - `sheet_name`: Target worksheet name 199 | - `data_range`: Range containing chart data 200 | - `chart_type`: Type of chart (line, bar, pie, scatter, area) 201 | - `target_cell`: Cell where to place chart 202 | - `title`: Optional chart title 203 | - `x_axis`: Optional X-axis label 204 | - `y_axis`: Optional Y-axis label 205 | - Returns: Success message 206 | 207 | ## Pivot Table Operations 208 | 209 | ### create_pivot_table 210 | 211 | Create pivot table in worksheet. 212 | 213 | ```python 214 | create_pivot_table( 215 | filepath: str, 216 | sheet_name: str, 217 | data_range: str, 218 | target_cell: str, 219 | rows: List[str], 220 | values: List[str], 221 | columns: List[str] = None, 222 | agg_func: str = "mean" 223 | ) -> str 224 | ``` 225 | 226 | - `filepath`: Path to Excel file 227 | - `sheet_name`: Target worksheet name 228 | - `data_range`: Range containing source data 229 | - `target_cell`: Cell where to place pivot table 230 | - `rows`: Fields for row labels 231 | - `values`: Fields for values 232 | - `columns`: Optional fields for column labels 233 | - `agg_func`: Aggregation function (sum, count, average, max, min) 234 | - Returns: Success message 235 | 236 | ## Worksheet Operations 237 | 238 | ### copy_worksheet 239 | 240 | Copy worksheet within workbook. 241 | 242 | ```python 243 | copy_worksheet(filepath: str, source_sheet: str, target_sheet: str) -> str 244 | ``` 245 | 246 | - `filepath`: Path to Excel file 247 | - `source_sheet`: Name of sheet to copy 248 | - `target_sheet`: Name for new sheet 249 | - Returns: Success message 250 | 251 | ### delete_worksheet 252 | 253 | Delete worksheet from workbook. 254 | 255 | ```python 256 | delete_worksheet(filepath: str, sheet_name: str) -> str 257 | ``` 258 | 259 | - `filepath`: Path to Excel file 260 | - `sheet_name`: Name of sheet to delete 261 | - Returns: Success message 262 | 263 | ### rename_worksheet 264 | 265 | Rename worksheet in workbook. 266 | 267 | ```python 268 | rename_worksheet(filepath: str, old_name: str, new_name: str) -> str 269 | ``` 270 | 271 | - `filepath`: Path to Excel file 272 | - `old_name`: Current sheet name 273 | - `new_name`: New sheet name 274 | - Returns: Success message 275 | 276 | ## Range Operations 277 | 278 | ### copy_range 279 | 280 | Copy a range of cells to another location. 281 | 282 | ```python 283 | copy_range( 284 | filepath: str, 285 | sheet_name: str, 286 | source_start: str, 287 | source_end: str, 288 | target_start: str, 289 | target_sheet: str = None 290 | ) -> str 291 | ``` 292 | 293 | - `filepath`: Path to Excel file 294 | - `sheet_name`: Source worksheet name 295 | - `source_start`: Starting cell of source range 296 | - `source_end`: Ending cell of source range 297 | - `target_start`: Starting cell for paste 298 | - `target_sheet`: Optional target worksheet name 299 | - Returns: Success message 300 | 301 | ### delete_range 302 | 303 | Delete a range of cells and shift remaining cells. 304 | 305 | ```python 306 | delete_range( 307 | filepath: str, 308 | sheet_name: str, 309 | start_cell: str, 310 | end_cell: str, 311 | shift_direction: str = "up" 312 | ) -> str 313 | ``` 314 | 315 | - `filepath`: Path to Excel file 316 | - `sheet_name`: Target worksheet name 317 | - `start_cell`: Starting cell of range 318 | - `end_cell`: Ending cell of range 319 | - `shift_direction`: Direction to shift cells ("up" or "left") 320 | - Returns: Success message 321 | 322 | ### validate_excel_range 323 | 324 | Validate if a range exists and is properly formatted. 325 | 326 | ```python 327 | validate_excel_range( 328 | filepath: str, 329 | sheet_name: str, 330 | start_cell: str, 331 | end_cell: str = None 332 | ) -> str 333 | ``` 334 | 335 | - `filepath`: Path to Excel file 336 | - `sheet_name`: Target worksheet name 337 | - `start_cell`: Starting cell of range 338 | - `end_cell`: Optional ending cell of range 339 | - Returns: Validation result message 340 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Excel 5 | MCP 6 | Server 7 | 8 | 9 | 10 | 11 | 12 | Excel ops without Excel. 13 | 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "excel-mcp-server" 3 | version = "0.1.3" 4 | description = "Excel MCP Server for manipulating Excel files" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "mcp[cli]>=1.6.0", 9 | "openpyxl>=3.1.2", 10 | "typer>=0.15.1" 11 | ] 12 | [[project.authors]] 13 | name = "haris" 14 | email = "haris.musa@outlook.com" 15 | 16 | [project.scripts] 17 | excel-mcp-server = "excel_mcp.__main__:app" 18 | 19 | [build-system] 20 | requires = ["hatchling"] 21 | build-backend = "hatchling.build" 22 | 23 | [tool.hatch.build.targets.wheel] 24 | packages = ["src/excel_mcp"] 25 | 26 | [tool.hatch.build] 27 | packages = ["src/excel_mcp"] 28 | -------------------------------------------------------------------------------- /src/excel_mcp/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typer 3 | 4 | from .server import run_sse, run_stdio 5 | 6 | app = typer.Typer(help="Excel MCP Server") 7 | 8 | @app.command() 9 | def sse(): 10 | """Start Excel MCP Server in SSE mode""" 11 | print("Excel MCP Server - SSE mode") 12 | print("----------------------") 13 | print("Press Ctrl+C to exit") 14 | try: 15 | asyncio.run(run_sse()) 16 | except KeyboardInterrupt: 17 | print("\nShutting down server...") 18 | except Exception as e: 19 | print(f"\nError: {e}") 20 | import traceback 21 | traceback.print_exc() 22 | finally: 23 | print("Service stopped.") 24 | 25 | @app.command() 26 | def stdio(): 27 | """Start Excel MCP Server in stdio mode""" 28 | try: 29 | run_stdio() 30 | except KeyboardInterrupt: 31 | print("\nShutting down server...") 32 | except Exception as e: 33 | print(f"\nError: {e}") 34 | import traceback 35 | traceback.print_exc() 36 | finally: 37 | print("Service stopped.") 38 | 39 | if __name__ == "__main__": 40 | app() -------------------------------------------------------------------------------- /src/excel_mcp/calculations.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import logging 3 | 4 | from .workbook import get_or_create_workbook 5 | from .cell_utils import validate_cell_reference 6 | from .exceptions import ValidationError, CalculationError 7 | from .validation import validate_formula 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | def apply_formula( 12 | filepath: str, 13 | sheet_name: str, 14 | cell: str, 15 | formula: str 16 | ) -> dict[str, Any]: 17 | """Apply any Excel formula to a cell.""" 18 | try: 19 | if not validate_cell_reference(cell): 20 | raise ValidationError(f"Invalid cell reference: {cell}") 21 | 22 | wb = get_or_create_workbook(filepath) 23 | if sheet_name not in wb.sheetnames: 24 | raise ValidationError(f"Sheet '{sheet_name}' not found") 25 | 26 | sheet = wb[sheet_name] 27 | 28 | # Ensure formula starts with = 29 | if not formula.startswith('='): 30 | formula = f'={formula}' 31 | 32 | # Validate formula syntax 33 | is_valid, message = validate_formula(formula) 34 | if not is_valid: 35 | raise CalculationError(f"Invalid formula syntax: {message}") 36 | 37 | try: 38 | # Apply formula to the cell 39 | cell_obj = sheet[cell] 40 | cell_obj.value = formula 41 | except Exception as e: 42 | raise CalculationError(f"Failed to apply formula to cell: {str(e)}") 43 | 44 | try: 45 | wb.save(filepath) 46 | except Exception as e: 47 | raise CalculationError(f"Failed to save workbook after applying formula: {str(e)}") 48 | 49 | return { 50 | "message": f"Applied formula '{formula}' to cell {cell}", 51 | "cell": cell, 52 | "formula": formula 53 | } 54 | 55 | except (ValidationError, CalculationError) as e: 56 | logger.error(str(e)) 57 | raise 58 | except Exception as e: 59 | logger.error(f"Failed to apply formula: {e}") 60 | raise CalculationError(str(e)) -------------------------------------------------------------------------------- /src/excel_mcp/cell_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from openpyxl.utils import column_index_from_string 4 | 5 | def parse_cell_range( 6 | cell_ref: str, 7 | end_ref: str | None = None 8 | ) -> tuple[int, int, int | None, int | None]: 9 | """Parse Excel cell reference into row and column indices.""" 10 | if end_ref: 11 | start_cell = cell_ref 12 | end_cell = end_ref 13 | else: 14 | start_cell = cell_ref 15 | end_cell = None 16 | 17 | match = re.match(r"([A-Z]+)([0-9]+)", start_cell.upper()) 18 | if not match: 19 | raise ValueError(f"Invalid cell reference: {start_cell}") 20 | col_str, row_str = match.groups() 21 | start_row = int(row_str) 22 | start_col = column_index_from_string(col_str) 23 | 24 | if end_cell: 25 | match = re.match(r"([A-Z]+)([0-9]+)", end_cell.upper()) 26 | if not match: 27 | raise ValueError(f"Invalid cell reference: {end_cell}") 28 | col_str, row_str = match.groups() 29 | end_row = int(row_str) 30 | end_col = column_index_from_string(col_str) 31 | else: 32 | end_row = None 33 | end_col = None 34 | 35 | return start_row, start_col, end_row, end_col 36 | 37 | def validate_cell_reference(cell_ref: str) -> bool: 38 | """Validate Excel cell reference format (e.g., 'A1', 'BC123')""" 39 | if not cell_ref: 40 | return False 41 | 42 | # Split into column and row parts 43 | col = row = "" 44 | for c in cell_ref: 45 | if c.isalpha(): 46 | if row: # Letters after numbers not allowed 47 | return False 48 | col += c 49 | elif c.isdigit(): 50 | row += c 51 | else: 52 | return False 53 | 54 | return bool(col and row) -------------------------------------------------------------------------------- /src/excel_mcp/chart.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Dict 2 | import logging 3 | from enum import Enum 4 | 5 | from openpyxl import load_workbook 6 | from openpyxl.chart import ( 7 | BarChart, LineChart, PieChart, ScatterChart, 8 | AreaChart, Reference, Series 9 | ) 10 | from openpyxl.chart.label import DataLabelList 11 | from openpyxl.chart.legend import Legend 12 | from openpyxl.chart.axis import ChartLines 13 | from openpyxl.drawing.spreadsheet_drawing import ( 14 | AnchorMarker, OneCellAnchor, SpreadsheetDrawing 15 | ) 16 | from openpyxl.utils import column_index_from_string 17 | 18 | from .cell_utils import parse_cell_range 19 | from .exceptions import ValidationError, ChartError 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | class ChartType(str, Enum): 24 | """Supported chart types""" 25 | LINE = "line" 26 | BAR = "bar" 27 | PIE = "pie" 28 | SCATTER = "scatter" 29 | AREA = "area" 30 | BUBBLE = "bubble" 31 | STOCK = "stock" 32 | SURFACE = "surface" 33 | RADAR = "radar" 34 | 35 | class ChartStyle: 36 | """Chart style configuration""" 37 | def __init__( 38 | self, 39 | title_size: int = 14, 40 | title_bold: bool = True, 41 | axis_label_size: int = 12, 42 | show_legend: bool = True, 43 | legend_position: str = "r", 44 | show_data_labels: bool = True, 45 | grid_lines: bool = False, 46 | style_id: int = 2 47 | ): 48 | self.title_size = title_size 49 | self.title_bold = title_bold 50 | self.axis_label_size = axis_label_size 51 | self.show_legend = show_legend 52 | self.legend_position = legend_position 53 | self.show_data_labels = show_data_labels 54 | self.grid_lines = grid_lines 55 | self.style_id = style_id 56 | 57 | def create_chart_in_sheet( 58 | filepath: str, 59 | sheet_name: str, 60 | data_range: str, 61 | chart_type: str, 62 | target_cell: str, 63 | title: str = "", 64 | x_axis: str = "", 65 | y_axis: str = "", 66 | style: Optional[Dict] = None 67 | ) -> dict[str, Any]: 68 | """Create chart in sheet with enhanced styling options""" 69 | try: 70 | wb = load_workbook(filepath) 71 | if sheet_name not in wb.sheetnames: 72 | logger.error(f"Sheet '{sheet_name}' not found") 73 | raise ValidationError(f"Sheet '{sheet_name}' not found") 74 | 75 | worksheet = wb[sheet_name] 76 | 77 | # Initialize collections if they don't exist 78 | if not hasattr(worksheet, '_drawings'): 79 | worksheet._drawings = [] 80 | if not hasattr(worksheet, '_charts'): 81 | worksheet._charts = [] 82 | 83 | # Parse the data range 84 | if "!" in data_range: 85 | range_sheet_name, cell_range = data_range.split("!") 86 | if range_sheet_name not in wb.sheetnames: 87 | logger.error(f"Sheet '{range_sheet_name}' referenced in data range not found") 88 | raise ValidationError(f"Sheet '{range_sheet_name}' referenced in data range not found") 89 | else: 90 | cell_range = data_range 91 | 92 | try: 93 | start_cell, end_cell = cell_range.split(":") 94 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 95 | except ValueError as e: 96 | logger.error(f"Invalid data range format: {e}") 97 | raise ValidationError(f"Invalid data range format: {str(e)}") 98 | 99 | # Validate chart type 100 | chart_classes = { 101 | "line": LineChart, 102 | "bar": BarChart, 103 | "pie": PieChart, 104 | "scatter": ScatterChart, 105 | "area": AreaChart 106 | } 107 | 108 | chart_type_lower = chart_type.lower() 109 | ChartClass = chart_classes.get(chart_type_lower) 110 | if not ChartClass: 111 | logger.error(f"Unsupported chart type: {chart_type}") 112 | raise ValidationError( 113 | f"Unsupported chart type: {chart_type}. " 114 | f"Supported types: {', '.join(chart_classes.keys())}" 115 | ) 116 | 117 | chart = ChartClass() 118 | 119 | # Basic chart settings 120 | chart.title = title 121 | if hasattr(chart, "x_axis"): 122 | chart.x_axis.title = x_axis 123 | if hasattr(chart, "y_axis"): 124 | chart.y_axis.title = y_axis 125 | 126 | try: 127 | # Create data references 128 | if chart_type_lower == "scatter": 129 | # For scatter charts, create series for each pair of columns 130 | for col in range(start_col + 1, end_col + 1): 131 | x_values = Reference( 132 | worksheet, 133 | min_row=start_row + 1, 134 | max_row=end_row, 135 | min_col=start_col 136 | ) 137 | y_values = Reference( 138 | worksheet, 139 | min_row=start_row + 1, 140 | max_row=end_row, 141 | min_col=col 142 | ) 143 | series = Series(y_values, x_values, title_from_data=True) 144 | chart.series.append(series) 145 | else: 146 | # For other chart types 147 | data = Reference( 148 | worksheet, 149 | min_row=start_row, 150 | max_row=end_row, 151 | min_col=start_col + 1, 152 | max_col=end_col 153 | ) 154 | cats = Reference( 155 | worksheet, 156 | min_row=start_row + 1, 157 | max_row=end_row, 158 | min_col=start_col 159 | ) 160 | chart.add_data(data, titles_from_data=True) 161 | chart.set_categories(cats) 162 | except Exception as e: 163 | logger.error(f"Failed to create chart data references: {e}") 164 | raise ChartError(f"Failed to create chart data references: {str(e)}") 165 | 166 | # Apply style if provided 167 | try: 168 | if style: 169 | if style.get("show_legend", True): 170 | chart.legend = Legend() 171 | chart.legend.position = style.get("legend_position", "r") 172 | else: 173 | chart.legend = None 174 | 175 | if style.get("show_data_labels", False): 176 | chart.dataLabels = DataLabelList() 177 | chart.dataLabels.showVal = True 178 | 179 | if style.get("grid_lines", False): 180 | if hasattr(chart, "x_axis"): 181 | chart.x_axis.majorGridlines = ChartLines() 182 | if hasattr(chart, "y_axis"): 183 | chart.y_axis.majorGridlines = ChartLines() 184 | except Exception as e: 185 | logger.error(f"Failed to apply chart style: {e}") 186 | raise ChartError(f"Failed to apply chart style: {str(e)}") 187 | 188 | # Set chart size 189 | chart.width = 15 190 | chart.height = 7.5 191 | 192 | # Create drawing and anchor 193 | try: 194 | drawing = SpreadsheetDrawing() 195 | drawing.chart = chart 196 | 197 | # Validate target cell format 198 | if not target_cell or not any(c.isalpha() for c in target_cell) or not any(c.isdigit() for c in target_cell): 199 | raise ValidationError(f"Invalid target cell format: {target_cell}") 200 | 201 | # Create anchor 202 | col = column_index_from_string(target_cell[0]) - 1 203 | row = int(target_cell[1:]) - 1 204 | anchor = OneCellAnchor() 205 | anchor._from = AnchorMarker(col=col, row=row) 206 | drawing.anchor = anchor 207 | 208 | # Add to worksheet 209 | worksheet._drawings.append(drawing) 210 | worksheet._charts.append(chart) 211 | except ValueError as e: 212 | logger.error(f"Invalid target cell: {e}") 213 | raise ValidationError(f"Invalid target cell: {str(e)}") 214 | except Exception as e: 215 | logger.error(f"Failed to create chart drawing: {e}") 216 | raise ChartError(f"Failed to create chart drawing: {str(e)}") 217 | 218 | try: 219 | wb.save(filepath) 220 | except Exception as e: 221 | logger.error(f"Failed to save workbook: {e}") 222 | raise ChartError(f"Failed to save workbook with chart: {str(e)}") 223 | 224 | return { 225 | "message": f"{chart_type.capitalize()} chart created successfully", 226 | "details": { 227 | "type": chart_type, 228 | "location": target_cell, 229 | "data_range": data_range 230 | } 231 | } 232 | 233 | except (ValidationError, ChartError): 234 | raise 235 | except Exception as e: 236 | logger.error(f"Unexpected error creating chart: {e}") 237 | raise ChartError(f"Unexpected error creating chart: {str(e)}") 238 | -------------------------------------------------------------------------------- /src/excel_mcp/data.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | import logging 4 | 5 | from openpyxl import load_workbook 6 | from openpyxl.styles import Font 7 | from openpyxl.worksheet.worksheet import Worksheet 8 | from openpyxl.utils import get_column_letter 9 | 10 | from .exceptions import DataError 11 | from .cell_utils import parse_cell_range 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def read_excel_range( 16 | filepath: Path | str, 17 | sheet_name: str, 18 | start_cell: str = "A1", 19 | end_cell: str | None = None, 20 | preview_only: bool = False 21 | ) -> list[dict[str, Any]]: 22 | """Read data from Excel range with optional preview mode""" 23 | try: 24 | wb = load_workbook(filepath, read_only=False) 25 | 26 | if sheet_name not in wb.sheetnames: 27 | raise DataError(f"Sheet '{sheet_name}' not found") 28 | 29 | ws = wb[sheet_name] 30 | 31 | # Parse start cell 32 | if ':' in start_cell: 33 | start_cell, end_cell = start_cell.split(':') 34 | 35 | # Get start coordinates 36 | try: 37 | start_coords = parse_cell_range(f"{start_cell}:{start_cell}") 38 | if not start_coords or not all(coord is not None for coord in start_coords[:2]): 39 | raise DataError(f"Invalid start cell reference: {start_cell}") 40 | start_row, start_col = start_coords[0], start_coords[1] 41 | except ValueError as e: 42 | raise DataError(f"Invalid start cell format: {str(e)}") 43 | 44 | # Determine end coordinates 45 | if end_cell: 46 | try: 47 | end_coords = parse_cell_range(f"{end_cell}:{end_cell}") 48 | if not end_coords or not all(coord is not None for coord in end_coords[:2]): 49 | raise DataError(f"Invalid end cell reference: {end_cell}") 50 | end_row, end_col = end_coords[0], end_coords[1] 51 | except ValueError as e: 52 | raise DataError(f"Invalid end cell format: {str(e)}") 53 | else: 54 | # Dynamically expand range until all values are empty 55 | end_row, end_col = start_row, start_col 56 | while end_row <= ws.max_row and any(ws.cell(row=end_row, column=c).value is not None for c in range(start_col, ws.max_column + 1)): 57 | end_row += 1 58 | while end_col <= ws.max_column and any(ws.cell(row=r, column=end_col).value is not None for r in range(start_row, ws.max_row + 1)): 59 | end_col += 1 60 | end_row -= 1 # Adjust back to last non-empty row 61 | end_col -= 1 # Adjust back to last non-empty column 62 | 63 | # Validate range bounds 64 | if start_row > ws.max_row or start_col > ws.max_column: 65 | raise DataError( 66 | f"Start cell out of bounds. Sheet dimensions are " 67 | f"A1:{get_column_letter(ws.max_column)}{ws.max_row}" 68 | ) 69 | 70 | data = [] 71 | for row in range(start_row, end_row + 1): 72 | row_data = [] 73 | for col in range(start_col, end_col + 1): 74 | cell = ws.cell(row=row, column=col) 75 | row_data.append(cell.value) 76 | if any(v is not None for v in row_data): 77 | data.append(row_data) 78 | 79 | wb.close() 80 | return data 81 | except DataError as e: 82 | logger.error(str(e)) 83 | raise 84 | except Exception as e: 85 | logger.error(f"Failed to read Excel range: {e}") 86 | raise DataError(str(e)) 87 | 88 | def write_data( 89 | filepath: str, 90 | sheet_name: str | None, 91 | data: list[list] | None, 92 | start_cell: str = "A1", 93 | ) -> dict[str, str]: 94 | """Write data to Excel sheet with workbook handling 95 | 96 | Headers are handled intelligently based on context. 97 | """ 98 | try: 99 | if not data: 100 | raise DataError("No data provided to write") 101 | 102 | wb = load_workbook(filepath) 103 | 104 | # If no sheet specified, use active sheet 105 | if not sheet_name: 106 | sheet_name = wb.active.title 107 | elif sheet_name not in wb.sheetnames: 108 | wb.create_sheet(sheet_name) 109 | 110 | ws = wb[sheet_name] 111 | 112 | # Validate start cell 113 | try: 114 | start_coords = parse_cell_range(start_cell) 115 | if not start_coords or not all(coord is not None for coord in start_coords[:2]): 116 | raise DataError(f"Invalid start cell reference: {start_cell}") 117 | except ValueError as e: 118 | raise DataError(f"Invalid start cell format: {str(e)}") 119 | 120 | if len(data) > 0: 121 | _write_data_to_worksheet(ws, data, start_cell) 122 | 123 | wb.save(filepath) 124 | wb.close() 125 | 126 | return {"message": f"Data written to {sheet_name}", "active_sheet": sheet_name} 127 | except DataError as e: 128 | logger.error(str(e)) 129 | raise 130 | except Exception as e: 131 | logger.error(f"Failed to write data: {e}") 132 | raise DataError(str(e)) 133 | 134 | def _looks_like_headers(row_dict): 135 | """Check if a data row appears to be headers (keys match values).""" 136 | return all( 137 | isinstance(value, str) and str(value).strip() == str(key).strip() 138 | for key, value in row_dict.items() 139 | ) 140 | 141 | def _check_for_headers_above(worksheet, start_row, start_col, headers): 142 | """Check if cells above start position contain headers.""" 143 | if start_row <= 1: 144 | return False # Nothing above row 1 145 | 146 | # Look for header-like content above 147 | for check_row in range(max(1, start_row - 5), start_row): 148 | # Count matches for this row 149 | header_count = 0 150 | cell_count = 0 151 | 152 | for i, header in enumerate(headers): 153 | if i >= 10: # Limit check to first 10 columns for performance 154 | break 155 | 156 | cell = worksheet.cell(row=check_row, column=start_col + i) 157 | cell_count += 1 158 | 159 | # Check if cell is formatted like a header (bold) 160 | is_formatted = cell.font.bold if hasattr(cell.font, 'bold') else False 161 | 162 | # Check for any content that could be a header 163 | if cell.value is not None: 164 | # Case 1: Direct match with expected header 165 | if str(cell.value).strip().lower() == str(header).strip().lower(): 166 | header_count += 2 # Give higher weight to exact matches 167 | # Case 2: Any formatted cell with content 168 | elif is_formatted and cell.value: 169 | header_count += 1 170 | # Case 3: Any cell with content in the first row we check 171 | elif check_row == max(1, start_row - 5): 172 | header_count += 0.5 173 | 174 | # If we have a significant number of matching cells, consider it a header row 175 | if cell_count > 0 and header_count >= cell_count * 0.5: 176 | return True 177 | 178 | # No headers found above 179 | return False 180 | 181 | def _determine_header_behavior(worksheet, start_row, start_col, data): 182 | """Determine if headers should be written based on context.""" 183 | if not data: 184 | return False # No data means no headers 185 | 186 | # Check if we're in the title area (rows 1-4) 187 | if start_row <= 4: 188 | return False # Don't add headers in title area 189 | 190 | # If we already have data in the sheet, be cautious about adding headers 191 | if worksheet.max_row > 1: 192 | # Check if the target row already has content 193 | has_content = any( 194 | worksheet.cell(row=start_row, column=start_col + i).value is not None 195 | for i in range(min(5, len(data[0].keys()))) 196 | ) 197 | 198 | if has_content: 199 | return False # Don't overwrite existing content with headers 200 | 201 | # Check if first row appears to be headers 202 | first_row_is_headers = _looks_like_headers(data[0]) 203 | 204 | # Check extensively for headers above (up to 5 rows) 205 | has_headers_above = _check_for_headers_above(worksheet, start_row, start_col, list(data[0].keys())) 206 | 207 | # Be conservative - don't add headers if we detect headers above or the data has headers 208 | if has_headers_above or first_row_is_headers: 209 | return False 210 | 211 | # If we're appending data immediately after existing data, don't add headers 212 | if any(worksheet.cell(row=start_row-1, column=start_col + i).value is not None 213 | for i in range(min(5, len(data[0].keys())))): 214 | return False 215 | 216 | # For completely new sheets or empty areas far from content, add headers 217 | return True 218 | 219 | def _write_data_to_worksheet( 220 | worksheet: Worksheet, 221 | data: list[list], 222 | start_cell: str = "A1", 223 | ) -> None: 224 | """Write data to worksheet with intelligent header handling""" 225 | try: 226 | if not data: 227 | raise DataError("No data provided to write") 228 | 229 | try: 230 | start_coords = parse_cell_range(start_cell) 231 | if not start_coords or not all(x is not None for x in start_coords[:2]): 232 | raise DataError(f"Invalid start cell reference: {start_cell}") 233 | start_row, start_col = start_coords[0], start_coords[1] 234 | except ValueError as e: 235 | raise DataError(f"Invalid start cell format: {str(e)}") 236 | 237 | # Write data 238 | for i, row in enumerate(data): 239 | for j, val in enumerate(row): 240 | worksheet.cell(row=start_row + i, column=start_col + j, value=val) 241 | except DataError as e: 242 | logger.error(str(e)) 243 | raise 244 | except Exception as e: 245 | logger.error(f"Failed to write worksheet data: {e}") 246 | raise DataError(str(e)) 247 | -------------------------------------------------------------------------------- /src/excel_mcp/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExcelMCPError(Exception): 2 | """Base exception for Excel MCP errors.""" 3 | pass 4 | 5 | class WorkbookError(ExcelMCPError): 6 | """Raised when workbook operations fail.""" 7 | pass 8 | 9 | class SheetError(ExcelMCPError): 10 | """Raised when sheet operations fail.""" 11 | pass 12 | 13 | class DataError(ExcelMCPError): 14 | """Raised when data operations fail.""" 15 | pass 16 | 17 | class ValidationError(ExcelMCPError): 18 | """Raised when validation fails.""" 19 | pass 20 | 21 | class FormattingError(ExcelMCPError): 22 | """Raised when formatting operations fail.""" 23 | pass 24 | 25 | class CalculationError(ExcelMCPError): 26 | """Raised when formula calculations fail.""" 27 | pass 28 | 29 | class PivotError(ExcelMCPError): 30 | """Raised when pivot table operations fail.""" 31 | pass 32 | 33 | class ChartError(ExcelMCPError): 34 | """Raised when chart operations fail.""" 35 | pass 36 | -------------------------------------------------------------------------------- /src/excel_mcp/formatting.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | from openpyxl.styles import ( 5 | PatternFill, Border, Side, Alignment, Protection, Font, 6 | Color 7 | ) 8 | from openpyxl.formatting.rule import ( 9 | ColorScaleRule, DataBarRule, IconSetRule, 10 | FormulaRule, CellIsRule 11 | ) 12 | 13 | from .workbook import get_or_create_workbook 14 | from .cell_utils import parse_cell_range, validate_cell_reference 15 | from .exceptions import ValidationError, FormattingError 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | def format_range( 20 | filepath: str, 21 | sheet_name: str, 22 | start_cell: str, 23 | end_cell: str = None, 24 | bold: bool = False, 25 | italic: bool = False, 26 | underline: bool = False, 27 | font_size: int = None, 28 | font_color: str = None, 29 | bg_color: str = None, 30 | border_style: str = None, 31 | border_color: str = None, 32 | number_format: str = None, 33 | alignment: str = None, 34 | wrap_text: bool = False, 35 | merge_cells: bool = False, 36 | protection: Dict[str, Any] = None, 37 | conditional_format: Dict[str, Any] = None 38 | ) -> Dict[str, Any]: 39 | """Apply formatting to a range of cells. 40 | 41 | This function handles all Excel formatting operations including: 42 | - Font properties (bold, italic, size, color, etc.) 43 | - Cell fill/background color 44 | - Borders (style and color) 45 | - Number formatting 46 | - Alignment and text wrapping 47 | - Cell merging 48 | - Protection 49 | - Conditional formatting 50 | 51 | Args: 52 | filepath: Path to Excel file 53 | sheet_name: Name of worksheet 54 | start_cell: Starting cell reference 55 | end_cell: Optional ending cell reference 56 | bold: Whether to make text bold 57 | italic: Whether to make text italic 58 | underline: Whether to underline text 59 | font_size: Font size in points 60 | font_color: Font color (hex code) 61 | bg_color: Background color (hex code) 62 | border_style: Border style (thin, medium, thick, double) 63 | border_color: Border color (hex code) 64 | number_format: Excel number format string 65 | alignment: Text alignment (left, center, right, justify) 66 | wrap_text: Whether to wrap text 67 | merge_cells: Whether to merge the range 68 | protection: Cell protection settings 69 | conditional_format: Conditional formatting rules 70 | 71 | Returns: 72 | Dictionary with operation status 73 | """ 74 | try: 75 | # Validate cell references 76 | if not validate_cell_reference(start_cell): 77 | raise ValidationError(f"Invalid start cell reference: {start_cell}") 78 | 79 | if end_cell and not validate_cell_reference(end_cell): 80 | raise ValidationError(f"Invalid end cell reference: {end_cell}") 81 | 82 | wb = get_or_create_workbook(filepath) 83 | if sheet_name not in wb.sheetnames: 84 | raise ValidationError(f"Sheet '{sheet_name}' not found") 85 | 86 | sheet = wb[sheet_name] 87 | 88 | # Get cell range coordinates 89 | try: 90 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 91 | except ValueError as e: 92 | raise ValidationError(f"Invalid cell range: {str(e)}") 93 | 94 | # If no end cell specified, use start cell coordinates 95 | if end_row is None: 96 | end_row = start_row 97 | if end_col is None: 98 | end_col = start_col 99 | 100 | # Apply font formatting 101 | font_args = { 102 | "bold": bold, 103 | "italic": italic, 104 | "underline": 'single' if underline else None, 105 | } 106 | if font_size is not None: 107 | font_args["size"] = font_size 108 | if font_color is not None: 109 | try: 110 | # Ensure color has FF prefix for full opacity 111 | font_color = font_color if font_color.startswith('FF') else f'FF{font_color}' 112 | font_args["color"] = Color(rgb=font_color) 113 | except ValueError as e: 114 | raise FormattingError(f"Invalid font color: {str(e)}") 115 | font = Font(**font_args) 116 | 117 | # Apply fill 118 | fill = None 119 | if bg_color is not None: 120 | try: 121 | # Ensure color has FF prefix for full opacity 122 | bg_color = bg_color if bg_color.startswith('FF') else f'FF{bg_color}' 123 | fill = PatternFill( 124 | start_color=Color(rgb=bg_color), 125 | end_color=Color(rgb=bg_color), 126 | fill_type='solid' 127 | ) 128 | except ValueError as e: 129 | raise FormattingError(f"Invalid background color: {str(e)}") 130 | 131 | # Apply borders 132 | border = None 133 | if border_style is not None: 134 | try: 135 | border_color = border_color if border_color else "000000" 136 | border_color = border_color if border_color.startswith('FF') else f'FF{border_color}' 137 | side = Side( 138 | style=border_style, 139 | color=Color(rgb=border_color) 140 | ) 141 | border = Border( 142 | left=side, 143 | right=side, 144 | top=side, 145 | bottom=side 146 | ) 147 | except ValueError as e: 148 | raise FormattingError(f"Invalid border settings: {str(e)}") 149 | 150 | # Apply alignment 151 | align = None 152 | if alignment is not None or wrap_text: 153 | try: 154 | align = Alignment( 155 | horizontal=alignment, 156 | vertical='center', 157 | wrap_text=wrap_text 158 | ) 159 | except ValueError as e: 160 | raise FormattingError(f"Invalid alignment settings: {str(e)}") 161 | 162 | # Apply protection 163 | protect = None 164 | if protection is not None: 165 | try: 166 | protect = Protection(**protection) 167 | except ValueError as e: 168 | raise FormattingError(f"Invalid protection settings: {str(e)}") 169 | 170 | # Apply formatting to range 171 | for row in range(start_row, end_row + 1): 172 | for col in range(start_col, end_col + 1): 173 | cell = sheet.cell(row=row, column=col) 174 | cell.font = font 175 | if fill is not None: 176 | cell.fill = fill 177 | if border is not None: 178 | cell.border = border 179 | if align is not None: 180 | cell.alignment = align 181 | if protect is not None: 182 | cell.protection = protect 183 | if number_format is not None: 184 | cell.number_format = number_format 185 | 186 | # Merge cells if requested 187 | if merge_cells and end_cell: 188 | try: 189 | range_str = f"{start_cell}:{end_cell}" 190 | sheet.merge_cells(range_str) 191 | except ValueError as e: 192 | raise FormattingError(f"Failed to merge cells: {str(e)}") 193 | 194 | # Apply conditional formatting 195 | if conditional_format is not None: 196 | range_str = f"{start_cell}:{end_cell}" if end_cell else start_cell 197 | rule_type = conditional_format.get('type') 198 | if not rule_type: 199 | raise FormattingError("Conditional format type not specified") 200 | 201 | params = conditional_format.get('params', {}) 202 | 203 | # Handle fill parameter for cell_is rule 204 | if rule_type == 'cell_is' and 'fill' in params: 205 | fill_params = params['fill'] 206 | if isinstance(fill_params, dict): 207 | try: 208 | fill_color = fill_params.get('fgColor', 'FFC7CE') # Default to light red 209 | fill_color = fill_color if fill_color.startswith('FF') else f'FF{fill_color}' 210 | params['fill'] = PatternFill( 211 | start_color=fill_color, 212 | end_color=fill_color, 213 | fill_type='solid' 214 | ) 215 | except ValueError as e: 216 | raise FormattingError(f"Invalid conditional format fill color: {str(e)}") 217 | 218 | try: 219 | if rule_type == 'color_scale': 220 | rule = ColorScaleRule(**params) 221 | elif rule_type == 'data_bar': 222 | rule = DataBarRule(**params) 223 | elif rule_type == 'icon_set': 224 | rule = IconSetRule(**params) 225 | elif rule_type == 'formula': 226 | rule = FormulaRule(**params) 227 | elif rule_type == 'cell_is': 228 | rule = CellIsRule(**params) 229 | else: 230 | raise FormattingError(f"Invalid conditional format type: {rule_type}") 231 | 232 | sheet.conditional_formatting.add(range_str, rule) 233 | except Exception as e: 234 | raise FormattingError(f"Failed to apply conditional formatting: {str(e)}") 235 | 236 | wb.save(filepath) 237 | 238 | range_str = f"{start_cell}:{end_cell}" if end_cell else start_cell 239 | return { 240 | "message": f"Applied formatting to range {range_str}", 241 | "range": range_str 242 | } 243 | 244 | except (ValidationError, FormattingError) as e: 245 | logger.error(str(e)) 246 | raise 247 | except Exception as e: 248 | logger.error(f"Failed to apply formatting: {e}") 249 | raise FormattingError(str(e)) 250 | -------------------------------------------------------------------------------- /src/excel_mcp/pivot.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import uuid 3 | import logging 4 | 5 | from openpyxl import load_workbook 6 | from openpyxl.utils import get_column_letter 7 | from openpyxl.worksheet.table import Table, TableStyleInfo 8 | from openpyxl.styles import Font 9 | 10 | from .data import read_excel_range 11 | from .cell_utils import parse_cell_range 12 | from .exceptions import ValidationError, PivotError 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | def create_pivot_table( 17 | filepath: str, 18 | sheet_name: str, 19 | data_range: str, 20 | rows: list[str], 21 | values: list[str], 22 | columns: list[str] | None = None, 23 | agg_func: str = "sum" 24 | ) -> dict[str, Any]: 25 | """Create pivot table in sheet using Excel table functionality 26 | 27 | Args: 28 | filepath: Path to Excel file 29 | sheet_name: Name of worksheet containing source data 30 | data_range: Source data range reference 31 | target_cell: Cell reference for pivot table position 32 | rows: Fields for row labels 33 | values: Fields for values 34 | columns: Optional fields for column labels 35 | agg_func: Aggregation function (sum, count, average, max, min) 36 | 37 | Returns: 38 | Dictionary with status message and pivot table dimensions 39 | """ 40 | try: 41 | wb = load_workbook(filepath) 42 | if sheet_name not in wb.sheetnames: 43 | raise ValidationError(f"Sheet '{sheet_name}' not found") 44 | 45 | # Parse ranges 46 | if ':' not in data_range: 47 | raise ValidationError("Data range must be in format 'A1:B2'") 48 | 49 | try: 50 | start_cell, end_cell = data_range.split(':') 51 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 52 | except ValueError as e: 53 | raise ValidationError(f"Invalid data range format: {str(e)}") 54 | 55 | if end_row is None or end_col is None: 56 | raise ValidationError("Invalid data range format: missing end coordinates") 57 | 58 | # Create range string 59 | data_range_str = f"{get_column_letter(start_col)}{start_row}:{get_column_letter(end_col)}{end_row}" 60 | 61 | # Read source data 62 | try: 63 | data = read_excel_range(filepath, sheet_name, start_cell, end_cell) 64 | if not data: 65 | raise PivotError("No data found in range") 66 | except Exception as e: 67 | raise PivotError(f"Failed to read source data: {str(e)}") 68 | 69 | # Validate aggregation function 70 | valid_agg_funcs = ["sum", "average", "count", "min", "max"] 71 | if agg_func.lower() not in valid_agg_funcs: 72 | raise ValidationError( 73 | f"Invalid aggregation function. Must be one of: {', '.join(valid_agg_funcs)}" 74 | ) 75 | 76 | # Clean up field names by removing aggregation suffixes 77 | def clean_field_name(field: str) -> str: 78 | field = str(field).strip() 79 | for suffix in [" (sum)", " (average)", " (count)", " (min)", " (max)"]: 80 | if field.lower().endswith(suffix): 81 | return field[:-len(suffix)] 82 | return field 83 | 84 | # Validate field names exist in data 85 | if data: 86 | first_row = data[0] 87 | available_fields = {clean_field_name(str(header)).lower() for header in first_row.keys()} 88 | 89 | for field_list, field_type in [(rows, "row"), (values, "value")]: 90 | for field in field_list: 91 | if clean_field_name(str(field)).lower() not in available_fields: 92 | raise ValidationError( 93 | f"Invalid {field_type} field '{field}'. " 94 | f"Available fields: {', '.join(sorted(available_fields))}" 95 | ) 96 | 97 | if columns: 98 | for field in columns: 99 | if clean_field_name(str(field)).lower() not in available_fields: 100 | raise ValidationError( 101 | f"Invalid column field '{field}'. " 102 | f"Available fields: {', '.join(sorted(available_fields))}" 103 | ) 104 | 105 | # Skip header row if it matches our fields 106 | if all( 107 | any(clean_field_name(str(header)).lower() == clean_field_name(str(field)).lower() 108 | for field in rows + values) 109 | for header in first_row.keys() 110 | ): 111 | data = data[1:] 112 | 113 | # Clean up row and value field names 114 | cleaned_rows = [clean_field_name(field) for field in rows] 115 | cleaned_values = [clean_field_name(field) for field in values] 116 | 117 | # Create pivot sheet 118 | pivot_sheet_name = f"{sheet_name}_pivot" 119 | if pivot_sheet_name in wb.sheetnames: 120 | wb.remove(wb[pivot_sheet_name]) 121 | pivot_ws = wb.create_sheet(pivot_sheet_name) 122 | 123 | # Write headers 124 | current_row = 1 125 | current_col = 1 126 | 127 | # Write row field headers 128 | for field in cleaned_rows: 129 | cell = pivot_ws.cell(row=current_row, column=current_col, value=field) 130 | cell.font = Font(bold=True) 131 | current_col += 1 132 | 133 | # Write value field headers 134 | for field in cleaned_values: 135 | cell = pivot_ws.cell(row=current_row, column=current_col, value=f"{field} ({agg_func})") 136 | cell.font = Font(bold=True) 137 | current_col += 1 138 | 139 | # Get unique values for each row field 140 | field_values = {} 141 | for field in cleaned_rows: 142 | all_values = [] 143 | for record in data: 144 | value = str(record.get(field, '')) 145 | all_values.append(value) 146 | field_values[field] = sorted(set(all_values)) 147 | 148 | # Generate all combinations of row field values 149 | row_combinations = _get_combinations(field_values) 150 | 151 | # Calculate table dimensions for formatting 152 | total_rows = len(row_combinations) + 1 # +1 for header 153 | total_cols = len(cleaned_rows) + len(cleaned_values) 154 | 155 | # Write data rows 156 | current_row = 2 157 | for combo in row_combinations: 158 | # Write row field values 159 | col = 1 160 | for field in cleaned_rows: 161 | pivot_ws.cell(row=current_row, column=col, value=combo[field]) 162 | col += 1 163 | 164 | # Filter data for current combination 165 | filtered_data = _filter_data(data, combo, {}) 166 | 167 | # Calculate and write aggregated values 168 | for value_field in cleaned_values: 169 | try: 170 | value = _aggregate_values(filtered_data, value_field, agg_func) 171 | pivot_ws.cell(row=current_row, column=col, value=value) 172 | except Exception as e: 173 | raise PivotError(f"Failed to aggregate values for field '{value_field}': {str(e)}") 174 | col += 1 175 | 176 | current_row += 1 177 | 178 | # Create a table for the pivot data 179 | try: 180 | pivot_range = f"A1:{get_column_letter(total_cols)}{total_rows}" 181 | pivot_table = Table( 182 | displayName=f"PivotTable_{uuid.uuid4().hex[:8]}", 183 | ref=pivot_range 184 | ) 185 | style = TableStyleInfo( 186 | name="TableStyleMedium9", 187 | showFirstColumn=False, 188 | showLastColumn=False, 189 | showRowStripes=True, 190 | showColumnStripes=True 191 | ) 192 | pivot_table.tableStyleInfo = style 193 | pivot_ws.add_table(pivot_table) 194 | except Exception as e: 195 | raise PivotError(f"Failed to create pivot table formatting: {str(e)}") 196 | 197 | try: 198 | wb.save(filepath) 199 | except Exception as e: 200 | raise PivotError(f"Failed to save workbook: {str(e)}") 201 | 202 | return { 203 | "message": "Summary table created successfully", 204 | "details": { 205 | "source_range": data_range_str, 206 | "pivot_sheet": pivot_sheet_name, 207 | "rows": cleaned_rows, 208 | "columns": columns or [], 209 | "values": cleaned_values, 210 | "aggregation": agg_func 211 | } 212 | } 213 | 214 | except (ValidationError, PivotError) as e: 215 | logger.error(str(e)) 216 | raise 217 | except Exception as e: 218 | logger.error(f"Failed to create pivot table: {e}") 219 | raise PivotError(str(e)) 220 | 221 | 222 | def _get_combinations(field_values: dict[str, set]) -> list[dict]: 223 | """Get all combinations of field values.""" 224 | result = [{}] 225 | for field, values in list(field_values.items()): # Convert to list to avoid runtime changes 226 | new_result = [] 227 | for combo in result: 228 | for value in sorted(values): # Sort for consistent ordering 229 | new_combo = combo.copy() 230 | new_combo[field] = value 231 | new_result.append(new_combo) 232 | result = new_result 233 | return result 234 | 235 | 236 | def _filter_data(data: list[dict], row_filters: dict, col_filters: dict) -> list[dict]: 237 | """Filter data based on row and column filters.""" 238 | result = [] 239 | for record in data: 240 | matches = True 241 | for field, value in row_filters.items(): 242 | if record.get(field) != value: 243 | matches = False 244 | break 245 | for field, value in col_filters.items(): 246 | if record.get(field) != value: 247 | matches = False 248 | break 249 | if matches: 250 | result.append(record) 251 | return result 252 | 253 | 254 | def _aggregate_values(data: list[dict], field: str, agg_func: str) -> float: 255 | """Aggregate values using the specified function.""" 256 | values = [record[field] for record in data if field in record and isinstance(record[field], (int, float))] 257 | if not values: 258 | return 0 259 | 260 | if agg_func == "sum": 261 | return sum(values) 262 | elif agg_func == "average": 263 | return sum(values) / len(values) 264 | elif agg_func == "count": 265 | return len(values) 266 | elif agg_func == "min": 267 | return min(values) 268 | elif agg_func == "max": 269 | return max(values) 270 | else: 271 | return sum(values) # Default to sum 272 | -------------------------------------------------------------------------------- /src/excel_mcp/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Any, List, Dict 4 | 5 | from mcp.server.fastmcp import FastMCP 6 | 7 | # Import exceptions 8 | from excel_mcp.exceptions import ( 9 | ValidationError, 10 | WorkbookError, 11 | SheetError, 12 | DataError, 13 | FormattingError, 14 | CalculationError, 15 | PivotError, 16 | ChartError 17 | ) 18 | 19 | # Import from excel_mcp package with consistent _impl suffixes 20 | from excel_mcp.validation import ( 21 | validate_formula_in_cell_operation as validate_formula_impl, 22 | validate_range_in_sheet_operation as validate_range_impl 23 | ) 24 | from excel_mcp.chart import create_chart_in_sheet as create_chart_impl 25 | from excel_mcp.workbook import get_workbook_info 26 | from excel_mcp.data import write_data 27 | from excel_mcp.pivot import create_pivot_table as create_pivot_table_impl 28 | from excel_mcp.sheet import ( 29 | copy_sheet, 30 | delete_sheet, 31 | rename_sheet, 32 | merge_range, 33 | unmerge_range, 34 | ) 35 | 36 | # Get project root directory path for log file path. 37 | # When using the stdio transmission method, 38 | # relative paths may cause log files to fail to create 39 | # due to the client's running location and permission issues, 40 | # resulting in the program not being able to run. 41 | # Thus using os.path.join(ROOT_DIR, "excel-mcp.log") instead. 42 | 43 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 44 | LOG_FILE = os.path.join(ROOT_DIR, "excel-mcp.log") 45 | 46 | # Initialize EXCEL_FILES_PATH variable without assigning a value 47 | EXCEL_FILES_PATH = None 48 | 49 | # Configure logging 50 | logging.basicConfig( 51 | level=logging.INFO, 52 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 53 | handlers=[ 54 | # Referring to https://github.com/modelcontextprotocol/python-sdk/issues/409#issuecomment-2816831318 55 | # The stdio mode server MUST NOT write anything to its stdout that is not a valid MCP message. 56 | logging.FileHandler(LOG_FILE) 57 | ], 58 | ) 59 | logger = logging.getLogger("excel-mcp") 60 | # Initialize FastMCP server 61 | mcp = FastMCP( 62 | "excel-mcp", 63 | version="0.1.3", 64 | description="Excel MCP Server for manipulating Excel files", 65 | dependencies=["openpyxl>=3.1.2"], 66 | env_vars={ 67 | "EXCEL_FILES_PATH": { 68 | "description": "Path to Excel files directory", 69 | "required": False, 70 | "default": EXCEL_FILES_PATH 71 | } 72 | } 73 | ) 74 | 75 | def get_excel_path(filename: str) -> str: 76 | """Get full path to Excel file. 77 | 78 | Args: 79 | filename: Name of Excel file 80 | 81 | Returns: 82 | Full path to Excel file 83 | """ 84 | # If filename is already an absolute path, return it 85 | if os.path.isabs(filename): 86 | return filename 87 | 88 | # Check if in SSE mode (EXCEL_FILES_PATH is not None) 89 | if EXCEL_FILES_PATH is None: 90 | # Must use absolute path 91 | raise ValueError(f"Invalid filename: {filename}, must be an absolute path when not in SSE mode") 92 | 93 | # In SSE mode, if it's a relative path, resolve it based on EXCEL_FILES_PATH 94 | return os.path.join(EXCEL_FILES_PATH, filename) 95 | 96 | @mcp.tool() 97 | def apply_formula( 98 | filepath: str, 99 | sheet_name: str, 100 | cell: str, 101 | formula: str, 102 | ) -> str: 103 | """ 104 | Apply Excel formula to cell. 105 | Excel formula will write to cell with verification. 106 | """ 107 | try: 108 | full_path = get_excel_path(filepath) 109 | # First validate the formula 110 | validation = validate_formula_impl(full_path, sheet_name, cell, formula) 111 | if isinstance(validation, dict) and "error" in validation: 112 | return f"Error: {validation['error']}" 113 | 114 | # If valid, apply the formula 115 | from excel_mcp.calculations import apply_formula as apply_formula_impl 116 | result = apply_formula_impl(full_path, sheet_name, cell, formula) 117 | return result["message"] 118 | except (ValidationError, CalculationError) as e: 119 | return f"Error: {str(e)}" 120 | except Exception as e: 121 | logger.error(f"Error applying formula: {e}") 122 | raise 123 | 124 | @mcp.tool() 125 | def validate_formula_syntax( 126 | filepath: str, 127 | sheet_name: str, 128 | cell: str, 129 | formula: str, 130 | ) -> str: 131 | """Validate Excel formula syntax without applying it.""" 132 | try: 133 | full_path = get_excel_path(filepath) 134 | result = validate_formula_impl(full_path, sheet_name, cell, formula) 135 | return result["message"] 136 | except (ValidationError, CalculationError) as e: 137 | return f"Error: {str(e)}" 138 | except Exception as e: 139 | logger.error(f"Error validating formula: {e}") 140 | raise 141 | 142 | @mcp.tool() 143 | def format_range( 144 | filepath: str, 145 | sheet_name: str, 146 | start_cell: str, 147 | end_cell: str = None, 148 | bold: bool = False, 149 | italic: bool = False, 150 | underline: bool = False, 151 | font_size: int = None, 152 | font_color: str = None, 153 | bg_color: str = None, 154 | border_style: str = None, 155 | border_color: str = None, 156 | number_format: str = None, 157 | alignment: str = None, 158 | wrap_text: bool = False, 159 | merge_cells: bool = False, 160 | protection: Dict[str, Any] = None, 161 | conditional_format: Dict[str, Any] = None 162 | ) -> str: 163 | """Apply formatting to a range of cells.""" 164 | try: 165 | full_path = get_excel_path(filepath) 166 | from excel_mcp.formatting import format_range as format_range_func 167 | 168 | result = format_range_func( 169 | filepath=full_path, 170 | sheet_name=sheet_name, 171 | start_cell=start_cell, 172 | end_cell=end_cell, 173 | bold=bold, 174 | italic=italic, 175 | underline=underline, 176 | font_size=font_size, 177 | font_color=font_color, 178 | bg_color=bg_color, 179 | border_style=border_style, 180 | border_color=border_color, 181 | number_format=number_format, 182 | alignment=alignment, 183 | wrap_text=wrap_text, 184 | merge_cells=merge_cells, 185 | protection=protection, 186 | conditional_format=conditional_format 187 | ) 188 | return "Range formatted successfully" 189 | except (ValidationError, FormattingError) as e: 190 | return f"Error: {str(e)}" 191 | except Exception as e: 192 | logger.error(f"Error formatting range: {e}") 193 | raise 194 | 195 | @mcp.tool() 196 | def read_data_from_excel( 197 | filepath: str, 198 | sheet_name: str, 199 | start_cell: str = "A1", 200 | end_cell: str = None, 201 | preview_only: bool = False 202 | ) -> str: 203 | """ 204 | Read data from Excel worksheet. 205 | 206 | Returns: 207 | Data from Excel worksheet as json string. list of lists or empty list if no data found. sublists are assumed to be rows. 208 | """ 209 | try: 210 | full_path = get_excel_path(filepath) 211 | from excel_mcp.data import read_excel_range 212 | result = read_excel_range(full_path, sheet_name, start_cell, end_cell, preview_only) 213 | if not result: 214 | return "No data found in specified range" 215 | # Convert the list of dicts to a formatted string 216 | data_str = "\n".join([str(row) for row in result]) 217 | return data_str 218 | except Exception as e: 219 | logger.error(f"Error reading data: {e}") 220 | raise 221 | 222 | @mcp.tool() 223 | def write_data_to_excel( 224 | filepath: str, 225 | sheet_name: str, 226 | data: List[List], 227 | start_cell: str = "A1", 228 | ) -> str: 229 | """ 230 | Write data to Excel worksheet. 231 | Excel formula will write to cell without any verification. 232 | 233 | PARAMETERS: 234 | filepath: Path to Excel file 235 | sheet_name: Name of worksheet to write to 236 | data: List of lists containing data to write to the worksheet, sublists are assumed to be rows 237 | start_cell: Cell to start writing to, default is "A1" 238 | 239 | """ 240 | try: 241 | full_path = get_excel_path(filepath) 242 | result = write_data(full_path, sheet_name, data, start_cell) 243 | return result["message"] 244 | except (ValidationError, DataError) as e: 245 | return f"Error: {str(e)}" 246 | except Exception as e: 247 | logger.error(f"Error writing data: {e}") 248 | raise 249 | 250 | @mcp.tool() 251 | def create_workbook(filepath: str) -> str: 252 | """Create new Excel workbook.""" 253 | try: 254 | full_path = get_excel_path(filepath) 255 | from excel_mcp.workbook import create_workbook as create_workbook_impl 256 | result = create_workbook_impl(full_path) 257 | return f"Created workbook at {full_path}" 258 | except WorkbookError as e: 259 | return f"Error: {str(e)}" 260 | except Exception as e: 261 | logger.error(f"Error creating workbook: {e}") 262 | raise 263 | 264 | @mcp.tool() 265 | def create_worksheet(filepath: str, sheet_name: str) -> str: 266 | """Create new worksheet in workbook.""" 267 | try: 268 | full_path = get_excel_path(filepath) 269 | from excel_mcp.workbook import create_sheet as create_worksheet_impl 270 | result = create_worksheet_impl(full_path, sheet_name) 271 | return result["message"] 272 | except (ValidationError, WorkbookError) as e: 273 | return f"Error: {str(e)}" 274 | except Exception as e: 275 | logger.error(f"Error creating worksheet: {e}") 276 | raise 277 | 278 | @mcp.tool() 279 | def create_chart( 280 | filepath: str, 281 | sheet_name: str, 282 | data_range: str, 283 | chart_type: str, 284 | target_cell: str, 285 | title: str = "", 286 | x_axis: str = "", 287 | y_axis: str = "" 288 | ) -> str: 289 | """Create chart in worksheet.""" 290 | try: 291 | full_path = get_excel_path(filepath) 292 | result = create_chart_impl( 293 | filepath=full_path, 294 | sheet_name=sheet_name, 295 | data_range=data_range, 296 | chart_type=chart_type, 297 | target_cell=target_cell, 298 | title=title, 299 | x_axis=x_axis, 300 | y_axis=y_axis 301 | ) 302 | return result["message"] 303 | except (ValidationError, ChartError) as e: 304 | return f"Error: {str(e)}" 305 | except Exception as e: 306 | logger.error(f"Error creating chart: {e}") 307 | raise 308 | 309 | @mcp.tool() 310 | def create_pivot_table( 311 | filepath: str, 312 | sheet_name: str, 313 | data_range: str, 314 | rows: List[str], 315 | values: List[str], 316 | columns: List[str] = None, 317 | agg_func: str = "mean" 318 | ) -> str: 319 | """Create pivot table in worksheet.""" 320 | try: 321 | full_path = get_excel_path(filepath) 322 | result = create_pivot_table_impl( 323 | filepath=full_path, 324 | sheet_name=sheet_name, 325 | data_range=data_range, 326 | rows=rows, 327 | values=values, 328 | columns=columns or [], 329 | agg_func=agg_func 330 | ) 331 | return result["message"] 332 | except (ValidationError, PivotError) as e: 333 | return f"Error: {str(e)}" 334 | except Exception as e: 335 | logger.error(f"Error creating pivot table: {e}") 336 | raise 337 | 338 | @mcp.tool() 339 | def copy_worksheet( 340 | filepath: str, 341 | source_sheet: str, 342 | target_sheet: str 343 | ) -> str: 344 | """Copy worksheet within workbook.""" 345 | try: 346 | full_path = get_excel_path(filepath) 347 | result = copy_sheet(full_path, source_sheet, target_sheet) 348 | return result["message"] 349 | except (ValidationError, SheetError) as e: 350 | return f"Error: {str(e)}" 351 | except Exception as e: 352 | logger.error(f"Error copying worksheet: {e}") 353 | raise 354 | 355 | @mcp.tool() 356 | def delete_worksheet( 357 | filepath: str, 358 | sheet_name: str 359 | ) -> str: 360 | """Delete worksheet from workbook.""" 361 | try: 362 | full_path = get_excel_path(filepath) 363 | result = delete_sheet(full_path, sheet_name) 364 | return result["message"] 365 | except (ValidationError, SheetError) as e: 366 | return f"Error: {str(e)}" 367 | except Exception as e: 368 | logger.error(f"Error deleting worksheet: {e}") 369 | raise 370 | 371 | @mcp.tool() 372 | def rename_worksheet( 373 | filepath: str, 374 | old_name: str, 375 | new_name: str 376 | ) -> str: 377 | """Rename worksheet in workbook.""" 378 | try: 379 | full_path = get_excel_path(filepath) 380 | result = rename_sheet(full_path, old_name, new_name) 381 | return result["message"] 382 | except (ValidationError, SheetError) as e: 383 | return f"Error: {str(e)}" 384 | except Exception as e: 385 | logger.error(f"Error renaming worksheet: {e}") 386 | raise 387 | 388 | @mcp.tool() 389 | def get_workbook_metadata( 390 | filepath: str, 391 | include_ranges: bool = False 392 | ) -> str: 393 | """Get metadata about workbook including sheets, ranges, etc.""" 394 | try: 395 | full_path = get_excel_path(filepath) 396 | result = get_workbook_info(full_path, include_ranges=include_ranges) 397 | return str(result) 398 | except WorkbookError as e: 399 | return f"Error: {str(e)}" 400 | except Exception as e: 401 | logger.error(f"Error getting workbook metadata: {e}") 402 | raise 403 | 404 | @mcp.tool() 405 | def merge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str: 406 | """Merge a range of cells.""" 407 | try: 408 | full_path = get_excel_path(filepath) 409 | result = merge_range(full_path, sheet_name, start_cell, end_cell) 410 | return result["message"] 411 | except (ValidationError, SheetError) as e: 412 | return f"Error: {str(e)}" 413 | except Exception as e: 414 | logger.error(f"Error merging cells: {e}") 415 | raise 416 | 417 | @mcp.tool() 418 | def unmerge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str: 419 | """Unmerge a range of cells.""" 420 | try: 421 | full_path = get_excel_path(filepath) 422 | result = unmerge_range(full_path, sheet_name, start_cell, end_cell) 423 | return result["message"] 424 | except (ValidationError, SheetError) as e: 425 | return f"Error: {str(e)}" 426 | except Exception as e: 427 | logger.error(f"Error unmerging cells: {e}") 428 | raise 429 | 430 | @mcp.tool() 431 | def copy_range( 432 | filepath: str, 433 | sheet_name: str, 434 | source_start: str, 435 | source_end: str, 436 | target_start: str, 437 | target_sheet: str = None 438 | ) -> str: 439 | """Copy a range of cells to another location.""" 440 | try: 441 | full_path = get_excel_path(filepath) 442 | from excel_mcp.sheet import copy_range_operation 443 | result = copy_range_operation( 444 | full_path, 445 | sheet_name, 446 | source_start, 447 | source_end, 448 | target_start, 449 | target_sheet 450 | ) 451 | return result["message"] 452 | except (ValidationError, SheetError) as e: 453 | return f"Error: {str(e)}" 454 | except Exception as e: 455 | logger.error(f"Error copying range: {e}") 456 | raise 457 | 458 | @mcp.tool() 459 | def delete_range( 460 | filepath: str, 461 | sheet_name: str, 462 | start_cell: str, 463 | end_cell: str, 464 | shift_direction: str = "up" 465 | ) -> str: 466 | """Delete a range of cells and shift remaining cells.""" 467 | try: 468 | full_path = get_excel_path(filepath) 469 | from excel_mcp.sheet import delete_range_operation 470 | result = delete_range_operation( 471 | full_path, 472 | sheet_name, 473 | start_cell, 474 | end_cell, 475 | shift_direction 476 | ) 477 | return result["message"] 478 | except (ValidationError, SheetError) as e: 479 | return f"Error: {str(e)}" 480 | except Exception as e: 481 | logger.error(f"Error deleting range: {e}") 482 | raise 483 | 484 | @mcp.tool() 485 | def validate_excel_range( 486 | filepath: str, 487 | sheet_name: str, 488 | start_cell: str, 489 | end_cell: str = None 490 | ) -> str: 491 | """Validate if a range exists and is properly formatted.""" 492 | try: 493 | full_path = get_excel_path(filepath) 494 | range_str = start_cell if not end_cell else f"{start_cell}:{end_cell}" 495 | result = validate_range_impl(full_path, sheet_name, range_str) 496 | return result["message"] 497 | except ValidationError as e: 498 | return f"Error: {str(e)}" 499 | except Exception as e: 500 | logger.error(f"Error validating range: {e}") 501 | raise 502 | 503 | async def run_sse(): 504 | """Run Excel MCP server in SSE mode.""" 505 | # Assign value to EXCEL_FILES_PATH in SSE mode 506 | global EXCEL_FILES_PATH 507 | EXCEL_FILES_PATH = os.environ.get("EXCEL_FILES_PATH", "./excel_files") 508 | # Create directory if it doesn't exist 509 | os.makedirs(EXCEL_FILES_PATH, exist_ok=True) 510 | 511 | try: 512 | logger.info(f"Starting Excel MCP server with SSE transport (files directory: {EXCEL_FILES_PATH})") 513 | await mcp.run_sse_async() 514 | except KeyboardInterrupt: 515 | logger.info("Server stopped by user") 516 | await mcp.shutdown() 517 | except Exception as e: 518 | logger.error(f"Server failed: {e}") 519 | raise 520 | finally: 521 | logger.info("Server shutdown complete") 522 | 523 | def run_stdio(): 524 | """Run Excel MCP server in stdio mode.""" 525 | # No need to assign EXCEL_FILES_PATH in stdio mode 526 | 527 | try: 528 | logger.info("Starting Excel MCP server with stdio transport") 529 | mcp.run(transport="stdio") 530 | except KeyboardInterrupt: 531 | logger.info("Server stopped by user") 532 | except Exception as e: 533 | logger.error(f"Server failed: {e}") 534 | raise 535 | finally: 536 | logger.info("Server shutdown complete") -------------------------------------------------------------------------------- /src/excel_mcp/sheet.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | from copy import copy 4 | 5 | from openpyxl import load_workbook 6 | from openpyxl.worksheet.worksheet import Worksheet 7 | from openpyxl.utils import get_column_letter, column_index_from_string 8 | from openpyxl.styles import Font, Border, PatternFill, Side 9 | 10 | from .cell_utils import parse_cell_range 11 | from .exceptions import SheetError, ValidationError 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def copy_sheet(filepath: str, source_sheet: str, target_sheet: str) -> dict[str, Any]: 16 | """Copy a worksheet within the same workbook.""" 17 | try: 18 | wb = load_workbook(filepath) 19 | if source_sheet not in wb.sheetnames: 20 | raise SheetError(f"Source sheet '{source_sheet}' not found") 21 | 22 | if target_sheet in wb.sheetnames: 23 | raise SheetError(f"Target sheet '{target_sheet}' already exists") 24 | 25 | source = wb[source_sheet] 26 | target = wb.copy_worksheet(source) 27 | target.title = target_sheet 28 | 29 | wb.save(filepath) 30 | return {"message": f"Sheet '{source_sheet}' copied to '{target_sheet}'"} 31 | except SheetError as e: 32 | logger.error(str(e)) 33 | raise 34 | except Exception as e: 35 | logger.error(f"Failed to copy sheet: {e}") 36 | raise SheetError(str(e)) 37 | 38 | def delete_sheet(filepath: str, sheet_name: str) -> dict[str, Any]: 39 | """Delete a worksheet from the workbook.""" 40 | try: 41 | wb = load_workbook(filepath) 42 | if sheet_name not in wb.sheetnames: 43 | raise SheetError(f"Sheet '{sheet_name}' not found") 44 | 45 | if len(wb.sheetnames) == 1: 46 | raise SheetError("Cannot delete the only sheet in workbook") 47 | 48 | del wb[sheet_name] 49 | wb.save(filepath) 50 | return {"message": f"Sheet '{sheet_name}' deleted"} 51 | except SheetError as e: 52 | logger.error(str(e)) 53 | raise 54 | except Exception as e: 55 | logger.error(f"Failed to delete sheet: {e}") 56 | raise SheetError(str(e)) 57 | 58 | def rename_sheet(filepath: str, old_name: str, new_name: str) -> dict[str, Any]: 59 | """Rename a worksheet.""" 60 | try: 61 | wb = load_workbook(filepath) 62 | if old_name not in wb.sheetnames: 63 | raise SheetError(f"Sheet '{old_name}' not found") 64 | 65 | if new_name in wb.sheetnames: 66 | raise SheetError(f"Sheet '{new_name}' already exists") 67 | 68 | sheet = wb[old_name] 69 | sheet.title = new_name 70 | wb.save(filepath) 71 | return {"message": f"Sheet renamed from '{old_name}' to '{new_name}'"} 72 | except SheetError as e: 73 | logger.error(str(e)) 74 | raise 75 | except Exception as e: 76 | logger.error(f"Failed to rename sheet: {e}") 77 | raise SheetError(str(e)) 78 | 79 | def format_range_string(start_row: int, start_col: int, end_row: int, end_col: int) -> str: 80 | """Format range string from row and column indices.""" 81 | return f"{get_column_letter(start_col)}{start_row}:{get_column_letter(end_col)}{end_row}" 82 | 83 | def copy_range( 84 | source_ws: Worksheet, 85 | target_ws: Worksheet, 86 | source_range: str, 87 | target_start: str | None = None, 88 | ) -> None: 89 | """Copy range from source worksheet to target worksheet.""" 90 | # Parse source range 91 | if ':' in source_range: 92 | source_start, source_end = source_range.split(':') 93 | else: 94 | source_start = source_range 95 | source_end = None 96 | 97 | src_start_row, src_start_col, src_end_row, src_end_col = parse_cell_range( 98 | source_start, source_end 99 | ) 100 | 101 | if src_end_row is None: 102 | src_end_row = src_start_row 103 | src_end_col = src_start_col 104 | 105 | if target_start is None: 106 | target_start = source_start 107 | 108 | tgt_start_row, tgt_start_col, _, _ = parse_cell_range(target_start) 109 | 110 | for i, row in enumerate(range(src_start_row, src_end_row + 1)): 111 | for j, col in enumerate(range(src_start_col, src_end_col + 1)): 112 | source_cell = source_ws.cell(row=row, column=col) 113 | target_cell = target_ws.cell(row=tgt_start_row + i, column=tgt_start_col + j) 114 | 115 | target_cell.value = source_cell.value 116 | 117 | try: 118 | # Copy font 119 | font_kwargs = {} 120 | if hasattr(source_cell.font, 'name'): 121 | font_kwargs['name'] = source_cell.font.name 122 | if hasattr(source_cell.font, 'size'): 123 | font_kwargs['size'] = source_cell.font.size 124 | if hasattr(source_cell.font, 'bold'): 125 | font_kwargs['bold'] = source_cell.font.bold 126 | if hasattr(source_cell.font, 'italic'): 127 | font_kwargs['italic'] = source_cell.font.italic 128 | if hasattr(source_cell.font, 'color'): 129 | font_color = None 130 | if source_cell.font.color: 131 | font_color = source_cell.font.color.rgb 132 | font_kwargs['color'] = font_color 133 | target_cell.font = Font(**font_kwargs) 134 | 135 | # Copy border 136 | new_border = Border() 137 | for side in ['left', 'right', 'top', 'bottom']: 138 | source_side = getattr(source_cell.border, side) 139 | if source_side and source_side.style: 140 | side_color = source_side.color.rgb if source_side.color else None 141 | setattr(new_border, side, Side( 142 | style=source_side.style, 143 | color=side_color 144 | )) 145 | target_cell.border = new_border 146 | 147 | # Copy fill 148 | if hasattr(source_cell, 'fill'): 149 | fill_kwargs = {'patternType': source_cell.fill.patternType} 150 | if hasattr(source_cell.fill, 'fgColor') and source_cell.fill.fgColor: 151 | fg_color = None 152 | if hasattr(source_cell.fill.fgColor, 'rgb'): 153 | fg_color = source_cell.fill.fgColor.rgb 154 | fill_kwargs['fgColor'] = fg_color 155 | if hasattr(source_cell.fill, 'bgColor') and source_cell.fill.bgColor: 156 | bg_color = None 157 | if hasattr(source_cell.fill.bgColor, 'rgb'): 158 | bg_color = source_cell.fill.bgColor.rgb 159 | fill_kwargs['bgColor'] = bg_color 160 | target_cell.fill = PatternFill(**fill_kwargs) 161 | 162 | # Copy number format and alignment 163 | if source_cell.number_format: 164 | target_cell.number_format = source_cell.number_format 165 | if source_cell.alignment: 166 | target_cell.alignment = source_cell.alignment 167 | 168 | except Exception: 169 | continue 170 | 171 | def delete_range(worksheet: Worksheet, start_cell: str, end_cell: str | None = None) -> None: 172 | """Delete contents and formatting of a range.""" 173 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 174 | 175 | if end_row is None: 176 | end_row = start_row 177 | end_col = start_col 178 | 179 | for row in range(start_row, end_row + 1): 180 | for col in range(start_col, end_col + 1): 181 | cell = worksheet.cell(row=row, column=col) 182 | cell.value = None 183 | cell.font = Font() 184 | cell.border = Border() 185 | cell.fill = PatternFill() 186 | cell.number_format = "General" 187 | cell.alignment = None 188 | 189 | def merge_range(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> dict[str, Any]: 190 | """Merge a range of cells.""" 191 | try: 192 | wb = load_workbook(filepath) 193 | if sheet_name not in wb.sheetnames: 194 | raise SheetError(f"Sheet '{sheet_name}' not found") 195 | 196 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 197 | 198 | if end_row is None or end_col is None: 199 | raise SheetError("Both start and end cells must be specified for merging") 200 | 201 | range_string = format_range_string(start_row, start_col, end_row, end_col) 202 | worksheet = wb[sheet_name] 203 | worksheet.merge_cells(range_string) 204 | wb.save(filepath) 205 | return {"message": f"Range '{range_string}' merged in sheet '{sheet_name}'"} 206 | except SheetError as e: 207 | logger.error(str(e)) 208 | raise 209 | except Exception as e: 210 | logger.error(f"Failed to merge range: {e}") 211 | raise SheetError(str(e)) 212 | 213 | def unmerge_range(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> dict[str, Any]: 214 | """Unmerge a range of cells.""" 215 | try: 216 | wb = load_workbook(filepath) 217 | if sheet_name not in wb.sheetnames: 218 | raise SheetError(f"Sheet '{sheet_name}' not found") 219 | 220 | worksheet = wb[sheet_name] 221 | 222 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 223 | 224 | if end_row is None or end_col is None: 225 | raise SheetError("Both start and end cells must be specified for unmerging") 226 | 227 | range_string = format_range_string(start_row, start_col, end_row, end_col) 228 | 229 | # Check if range is actually merged 230 | merged_ranges = worksheet.merged_cells.ranges 231 | target_range = range_string.upper() 232 | 233 | if not any(str(merged_range).upper() == target_range for merged_range in merged_ranges): 234 | raise SheetError(f"Range '{range_string}' is not merged") 235 | 236 | worksheet.unmerge_cells(range_string) 237 | wb.save(filepath) 238 | return {"message": f"Range '{range_string}' unmerged successfully"} 239 | except SheetError as e: 240 | logger.error(str(e)) 241 | raise 242 | except Exception as e: 243 | logger.error(f"Failed to unmerge range: {e}") 244 | raise SheetError(str(e)) 245 | 246 | def copy_range_operation( 247 | filepath: str, 248 | sheet_name: str, 249 | source_start: str, 250 | source_end: str, 251 | target_start: str, 252 | target_sheet: str = None 253 | ) -> dict: 254 | """Copy a range of cells to another location.""" 255 | try: 256 | wb = load_workbook(filepath) 257 | if sheet_name not in wb.sheetnames: 258 | logger.error(f"Sheet '{sheet_name}' not found") 259 | raise ValidationError(f"Sheet '{sheet_name}' not found") 260 | 261 | source_ws = wb[sheet_name] 262 | target_ws = wb[target_sheet] if target_sheet else source_ws 263 | 264 | # Parse source range 265 | try: 266 | start_row, start_col, end_row, end_col = parse_cell_range(source_start, source_end) 267 | except ValueError as e: 268 | logger.error(f"Invalid source range: {e}") 269 | raise ValidationError(f"Invalid source range: {str(e)}") 270 | 271 | # Parse target starting point 272 | try: 273 | target_row = int(''.join(filter(str.isdigit, target_start))) 274 | target_col = column_index_from_string(''.join(filter(str.isalpha, target_start))) 275 | except ValueError as e: 276 | logger.error(f"Invalid target cell: {e}") 277 | raise ValidationError(f"Invalid target cell: {str(e)}") 278 | 279 | # Copy the range 280 | row_offset = target_row - start_row 281 | col_offset = target_col - start_col 282 | 283 | for i in range(start_row, end_row + 1): 284 | for j in range(start_col, end_col + 1): 285 | source_cell = source_ws.cell(row=i, column=j) 286 | target_cell = target_ws.cell(row=i + row_offset, column=j + col_offset) 287 | target_cell.value = source_cell.value 288 | if source_cell.has_style: 289 | target_cell._style = copy(source_cell._style) 290 | 291 | wb.save(filepath) 292 | return {"message": f"Range copied successfully"} 293 | 294 | except (ValidationError, SheetError): 295 | raise 296 | except Exception as e: 297 | logger.error(f"Failed to copy range: {e}") 298 | raise SheetError(f"Failed to copy range: {str(e)}") 299 | 300 | def delete_range_operation( 301 | filepath: str, 302 | sheet_name: str, 303 | start_cell: str, 304 | end_cell: str | None = None, 305 | shift_direction: str = "up" 306 | ) -> dict[str, Any]: 307 | """Delete a range of cells and shift remaining cells.""" 308 | try: 309 | wb = load_workbook(filepath) 310 | if sheet_name not in wb.sheetnames: 311 | raise SheetError(f"Sheet '{sheet_name}' not found") 312 | 313 | worksheet = wb[sheet_name] 314 | 315 | # Validate range 316 | try: 317 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 318 | if end_row and end_row > worksheet.max_row: 319 | raise SheetError(f"End row {end_row} out of bounds (1-{worksheet.max_row})") 320 | if end_col and end_col > worksheet.max_column: 321 | raise SheetError(f"End column {end_col} out of bounds (1-{worksheet.max_column})") 322 | except ValueError as e: 323 | raise SheetError(f"Invalid range: {str(e)}") 324 | 325 | # Validate shift direction 326 | if shift_direction not in ["up", "left"]: 327 | raise ValidationError(f"Invalid shift direction: {shift_direction}. Must be 'up' or 'left'") 328 | 329 | range_string = format_range_string( 330 | start_row, start_col, 331 | end_row or start_row, 332 | end_col or start_col 333 | ) 334 | 335 | # Delete range contents 336 | delete_range(worksheet, start_cell, end_cell) 337 | 338 | # Shift cells if needed 339 | if shift_direction == "up": 340 | worksheet.delete_rows(start_row, (end_row or start_row) - start_row + 1) 341 | elif shift_direction == "left": 342 | worksheet.delete_cols(start_col, (end_col or start_col) - start_col + 1) 343 | 344 | wb.save(filepath) 345 | 346 | return {"message": f"Range {range_string} deleted successfully"} 347 | except (ValidationError, SheetError) as e: 348 | logger.error(str(e)) 349 | raise 350 | except Exception as e: 351 | logger.error(f"Failed to delete range: {e}") 352 | raise SheetError(str(e)) 353 | -------------------------------------------------------------------------------- /src/excel_mcp/validation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Any 4 | 5 | from openpyxl import load_workbook 6 | from openpyxl.utils import get_column_letter 7 | from openpyxl.worksheet.worksheet import Worksheet 8 | 9 | from .cell_utils import parse_cell_range, validate_cell_reference 10 | from .exceptions import ValidationError 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | def validate_formula_in_cell_operation( 15 | filepath: str, 16 | sheet_name: str, 17 | cell: str, 18 | formula: str 19 | ) -> dict[str, Any]: 20 | """Validate Excel formula before writing""" 21 | try: 22 | wb = load_workbook(filepath) 23 | if sheet_name not in wb.sheetnames: 24 | raise ValidationError(f"Sheet '{sheet_name}' not found") 25 | 26 | if not validate_cell_reference(cell): 27 | raise ValidationError(f"Invalid cell reference: {cell}") 28 | 29 | # First validate the provided formula's syntax 30 | is_valid, message = validate_formula(formula) 31 | if not is_valid: 32 | raise ValidationError(f"Invalid formula syntax: {message}") 33 | 34 | # Additional validation for cell references in formula 35 | cell_refs = re.findall(r'[A-Z]+[0-9]+(?::[A-Z]+[0-9]+)?', formula) 36 | for ref in cell_refs: 37 | if ':' in ref: # Range reference 38 | start, end = ref.split(':') 39 | if not (validate_cell_reference(start) and validate_cell_reference(end)): 40 | raise ValidationError(f"Invalid cell range reference in formula: {ref}") 41 | else: # Single cell reference 42 | if not validate_cell_reference(ref): 43 | raise ValidationError(f"Invalid cell reference in formula: {ref}") 44 | 45 | # Now check if there's a formula in the cell and compare 46 | sheet = wb[sheet_name] 47 | cell_obj = sheet[cell] 48 | current_formula = cell_obj.value 49 | 50 | # If cell has a formula (starts with =) 51 | if isinstance(current_formula, str) and current_formula.startswith('='): 52 | if formula.startswith('='): 53 | if current_formula != formula: 54 | return { 55 | "message": "Formula is valid but doesn't match cell content", 56 | "valid": True, 57 | "matches": False, 58 | "cell": cell, 59 | "provided_formula": formula, 60 | "current_formula": current_formula 61 | } 62 | else: 63 | if current_formula != f"={formula}": 64 | return { 65 | "message": "Formula is valid but doesn't match cell content", 66 | "valid": True, 67 | "matches": False, 68 | "cell": cell, 69 | "provided_formula": formula, 70 | "current_formula": current_formula 71 | } 72 | else: 73 | return { 74 | "message": "Formula is valid and matches cell content", 75 | "valid": True, 76 | "matches": True, 77 | "cell": cell, 78 | "formula": formula 79 | } 80 | else: 81 | return { 82 | "message": "Formula is valid but cell contains no formula", 83 | "valid": True, 84 | "matches": False, 85 | "cell": cell, 86 | "provided_formula": formula, 87 | "current_content": str(current_formula) if current_formula else "" 88 | } 89 | 90 | except ValidationError as e: 91 | logger.error(str(e)) 92 | raise 93 | except Exception as e: 94 | logger.error(f"Failed to validate formula: {e}") 95 | raise ValidationError(str(e)) 96 | 97 | def validate_range_in_sheet_operation( 98 | filepath: str, 99 | sheet_name: str, 100 | start_cell: str, 101 | end_cell: str | None = None, 102 | ) -> dict[str, Any]: 103 | """Validate if a range exists in a worksheet and return data range info.""" 104 | try: 105 | wb = load_workbook(filepath) 106 | if sheet_name not in wb.sheetnames: 107 | raise ValidationError(f"Sheet '{sheet_name}' not found") 108 | 109 | worksheet = wb[sheet_name] 110 | 111 | # Get actual data dimensions 112 | data_max_row = worksheet.max_row 113 | data_max_col = worksheet.max_column 114 | 115 | # Validate range 116 | try: 117 | start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell) 118 | except ValueError as e: 119 | raise ValidationError(f"Invalid range: {str(e)}") 120 | 121 | # If end not specified, use start 122 | if end_row is None: 123 | end_row = start_row 124 | if end_col is None: 125 | end_col = start_col 126 | 127 | # Validate bounds against maximum possible Excel limits 128 | is_valid, message = validate_range_bounds( 129 | worksheet, start_row, start_col, end_row, end_col 130 | ) 131 | if not is_valid: 132 | raise ValidationError(message) 133 | 134 | range_str = f"{start_cell}" if end_cell is None else f"{start_cell}:{end_cell}" 135 | data_range_str = f"A1:{get_column_letter(data_max_col)}{data_max_row}" 136 | 137 | # Check if range is within data or extends beyond 138 | extends_beyond_data = ( 139 | end_row > data_max_row or 140 | end_col > data_max_col 141 | ) 142 | 143 | return { 144 | "message": ( 145 | f"Range '{range_str}' is valid. " 146 | f"Sheet contains data in range '{data_range_str}'" 147 | ), 148 | "valid": True, 149 | "range": range_str, 150 | "data_range": data_range_str, 151 | "extends_beyond_data": extends_beyond_data, 152 | "data_dimensions": { 153 | "max_row": data_max_row, 154 | "max_col": data_max_col, 155 | "max_col_letter": get_column_letter(data_max_col) 156 | } 157 | } 158 | except ValidationError as e: 159 | logger.error(str(e)) 160 | raise 161 | except Exception as e: 162 | logger.error(f"Failed to validate range: {e}") 163 | raise ValidationError(str(e)) 164 | 165 | def validate_formula(formula: str) -> tuple[bool, str]: 166 | """Validate Excel formula syntax and safety""" 167 | if not formula.startswith("="): 168 | return False, "Formula must start with '='" 169 | 170 | # Remove the '=' prefix for validation 171 | formula = formula[1:] 172 | 173 | # Check for balanced parentheses 174 | parens = 0 175 | for c in formula: 176 | if c == "(": 177 | parens += 1 178 | elif c == ")": 179 | parens -= 1 180 | if parens < 0: 181 | return False, "Unmatched closing parenthesis" 182 | 183 | if parens > 0: 184 | return False, "Unclosed parenthesis" 185 | 186 | # Basic function name validation 187 | func_pattern = r"([A-Z]+)\(" 188 | funcs = re.findall(func_pattern, formula) 189 | unsafe_funcs = {"INDIRECT", "HYPERLINK", "WEBSERVICE", "DGET", "RTD"} 190 | 191 | for func in funcs: 192 | if func in unsafe_funcs: 193 | return False, f"Unsafe function: {func}" 194 | 195 | return True, "Formula is valid" 196 | 197 | 198 | def validate_range_bounds( 199 | worksheet: Worksheet, 200 | start_row: int, 201 | start_col: int, 202 | end_row: int | None = None, 203 | end_col: int | None = None, 204 | ) -> tuple[bool, str]: 205 | """Validate that cell range is within worksheet bounds""" 206 | max_row = worksheet.max_row 207 | max_col = worksheet.max_column 208 | 209 | try: 210 | # Check start cell bounds 211 | if start_row < 1 or start_row > max_row: 212 | return False, f"Start row {start_row} out of bounds (1-{max_row})" 213 | if start_col < 1 or start_col > max_col: 214 | return False, ( 215 | f"Start column {get_column_letter(start_col)} " 216 | f"out of bounds (A-{get_column_letter(max_col)})" 217 | ) 218 | 219 | # If end cell specified, check its bounds 220 | if end_row is not None and end_col is not None: 221 | if end_row < start_row: 222 | return False, "End row cannot be before start row" 223 | if end_col < start_col: 224 | return False, "End column cannot be before start column" 225 | if end_row > max_row: 226 | return False, f"End row {end_row} out of bounds (1-{max_row})" 227 | if end_col > max_col: 228 | return False, ( 229 | f"End column {get_column_letter(end_col)} " 230 | f"out of bounds (A-{get_column_letter(max_col)})" 231 | ) 232 | 233 | return True, "Range is valid" 234 | except Exception as e: 235 | return False, f"Invalid range: {e!s}" -------------------------------------------------------------------------------- /src/excel_mcp/workbook.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from openpyxl import Workbook, load_workbook 6 | from openpyxl.utils import get_column_letter 7 | 8 | from .exceptions import WorkbookError 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | def create_workbook(filepath: str, sheet_name: str = "Sheet1") -> dict[str, Any]: 13 | """Create a new Excel workbook with optional custom sheet name""" 14 | try: 15 | wb = Workbook() 16 | # Rename default sheet 17 | if "Sheet" in wb.sheetnames: 18 | sheet = wb["Sheet"] 19 | sheet.title = sheet_name 20 | else: 21 | wb.create_sheet(sheet_name) 22 | 23 | path = Path(filepath) 24 | path.parent.mkdir(parents=True, exist_ok=True) 25 | wb.save(str(path)) 26 | return { 27 | "message": f"Created workbook: {filepath}", 28 | "active_sheet": sheet_name, 29 | "workbook": wb 30 | } 31 | except Exception as e: 32 | logger.error(f"Failed to create workbook: {e}") 33 | raise WorkbookError(f"Failed to create workbook: {e!s}") 34 | 35 | def get_or_create_workbook(filepath: str) -> Workbook: 36 | """Get existing workbook or create new one if it doesn't exist""" 37 | try: 38 | return load_workbook(filepath) 39 | except FileNotFoundError: 40 | return create_workbook(filepath)["workbook"] 41 | 42 | def create_sheet(filepath: str, sheet_name: str) -> dict: 43 | """Create a new worksheet in the workbook if it doesn't exist.""" 44 | try: 45 | wb = load_workbook(filepath) 46 | 47 | # Check if sheet already exists 48 | if sheet_name in wb.sheetnames: 49 | raise WorkbookError(f"Sheet {sheet_name} already exists") 50 | 51 | # Create new sheet 52 | wb.create_sheet(sheet_name) 53 | wb.save(filepath) 54 | wb.close() 55 | return {"message": f"Sheet {sheet_name} created successfully"} 56 | except WorkbookError as e: 57 | logger.error(str(e)) 58 | raise 59 | except Exception as e: 60 | logger.error(f"Failed to create sheet: {e}") 61 | raise WorkbookError(str(e)) 62 | 63 | def get_workbook_info(filepath: str, include_ranges: bool = False) -> dict[str, Any]: 64 | """Get metadata about workbook including sheets, ranges, etc.""" 65 | try: 66 | path = Path(filepath) 67 | if not path.exists(): 68 | raise WorkbookError(f"File not found: {filepath}") 69 | 70 | wb = load_workbook(filepath, read_only=False) 71 | 72 | info = { 73 | "filename": path.name, 74 | "sheets": wb.sheetnames, 75 | "size": path.stat().st_size, 76 | "modified": path.stat().st_mtime 77 | } 78 | 79 | if include_ranges: 80 | # Add used ranges for each sheet 81 | ranges = {} 82 | for sheet_name in wb.sheetnames: 83 | ws = wb[sheet_name] 84 | if ws.max_row > 0 and ws.max_column > 0: 85 | ranges[sheet_name] = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}" 86 | info["used_ranges"] = ranges 87 | 88 | wb.close() 89 | return info 90 | 91 | except WorkbookError as e: 92 | logger.error(str(e)) 93 | raise 94 | except Exception as e: 95 | logger.error(f"Failed to get workbook info: {e}") 96 | raise WorkbookError(str(e)) 97 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "annotated-types" 7 | version = "0.7.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.8.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 20 | { name = "idna" }, 21 | { name = "sniffio" }, 22 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 23 | ] 24 | sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } 25 | wheels = [ 26 | { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, 27 | ] 28 | 29 | [[package]] 30 | name = "certifi" 31 | version = "2025.1.31" 32 | source = { registry = "https://pypi.org/simple" } 33 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 34 | wheels = [ 35 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 36 | ] 37 | 38 | [[package]] 39 | name = "click" 40 | version = "8.1.8" 41 | source = { registry = "https://pypi.org/simple" } 42 | dependencies = [ 43 | { name = "colorama", marker = "sys_platform == 'win32'" }, 44 | ] 45 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 46 | wheels = [ 47 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 48 | ] 49 | 50 | [[package]] 51 | name = "colorama" 52 | version = "0.4.6" 53 | source = { registry = "https://pypi.org/simple" } 54 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 57 | ] 58 | 59 | [[package]] 60 | name = "et-xmlfile" 61 | version = "2.0.0" 62 | source = { registry = "https://pypi.org/simple" } 63 | sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 } 64 | wheels = [ 65 | { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 }, 66 | ] 67 | 68 | [[package]] 69 | name = "excel-mcp-server" 70 | version = "0.1.1" 71 | source = { editable = "." } 72 | dependencies = [ 73 | { name = "mcp", extra = ["cli"] }, 74 | { name = "openpyxl" }, 75 | ] 76 | 77 | [package.metadata] 78 | requires-dist = [ 79 | { name = "mcp", extras = ["cli"], specifier = ">=1.2.0" }, 80 | { name = "openpyxl", specifier = ">=3.1.2" }, 81 | ] 82 | 83 | [[package]] 84 | name = "exceptiongroup" 85 | version = "1.2.2" 86 | source = { registry = "https://pypi.org/simple" } 87 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 88 | wheels = [ 89 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 90 | ] 91 | 92 | [[package]] 93 | name = "h11" 94 | version = "0.14.0" 95 | source = { registry = "https://pypi.org/simple" } 96 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 97 | wheels = [ 98 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 99 | ] 100 | 101 | [[package]] 102 | name = "httpcore" 103 | version = "1.0.7" 104 | source = { registry = "https://pypi.org/simple" } 105 | dependencies = [ 106 | { name = "certifi" }, 107 | { name = "h11" }, 108 | ] 109 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 110 | wheels = [ 111 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 112 | ] 113 | 114 | [[package]] 115 | name = "httpx" 116 | version = "0.28.1" 117 | source = { registry = "https://pypi.org/simple" } 118 | dependencies = [ 119 | { name = "anyio" }, 120 | { name = "certifi" }, 121 | { name = "httpcore" }, 122 | { name = "idna" }, 123 | ] 124 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 125 | wheels = [ 126 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 127 | ] 128 | 129 | [[package]] 130 | name = "httpx-sse" 131 | version = "0.4.0" 132 | source = { registry = "https://pypi.org/simple" } 133 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 134 | wheels = [ 135 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 136 | ] 137 | 138 | [[package]] 139 | name = "idna" 140 | version = "3.10" 141 | source = { registry = "https://pypi.org/simple" } 142 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 143 | wheels = [ 144 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 145 | ] 146 | 147 | [[package]] 148 | name = "markdown-it-py" 149 | version = "3.0.0" 150 | source = { registry = "https://pypi.org/simple" } 151 | dependencies = [ 152 | { name = "mdurl" }, 153 | ] 154 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 155 | wheels = [ 156 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 157 | ] 158 | 159 | [[package]] 160 | name = "mcp" 161 | version = "1.2.1" 162 | source = { registry = "https://pypi.org/simple" } 163 | dependencies = [ 164 | { name = "anyio" }, 165 | { name = "httpx" }, 166 | { name = "httpx-sse" }, 167 | { name = "pydantic" }, 168 | { name = "pydantic-settings" }, 169 | { name = "sse-starlette" }, 170 | { name = "starlette" }, 171 | { name = "uvicorn" }, 172 | ] 173 | sdist = { url = "https://files.pythonhosted.org/packages/fc/30/51e4555826126e3954fa2ab1e934bf74163c5fe05e98f38ca4d0f8abbf63/mcp-1.2.1.tar.gz", hash = "sha256:c9d43dbfe943aa1530e2be8f54b73af3ebfb071243827b4483d421684806cb45", size = 103968 } 174 | wheels = [ 175 | { url = "https://files.pythonhosted.org/packages/4c/0d/6770742a84c8aa1d36c0d628896a380584c5759612e66af7446af07d8775/mcp-1.2.1-py3-none-any.whl", hash = "sha256:579bf9c9157850ebb1344f3ca6f7a3021b0123c44c9f089ef577a7062522f0fd", size = 66453 }, 176 | ] 177 | 178 | [package.optional-dependencies] 179 | cli = [ 180 | { name = "python-dotenv" }, 181 | { name = "typer" }, 182 | ] 183 | 184 | [[package]] 185 | name = "mdurl" 186 | version = "0.1.2" 187 | source = { registry = "https://pypi.org/simple" } 188 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 189 | wheels = [ 190 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 191 | ] 192 | 193 | [[package]] 194 | name = "openpyxl" 195 | version = "3.1.5" 196 | source = { registry = "https://pypi.org/simple" } 197 | dependencies = [ 198 | { name = "et-xmlfile" }, 199 | ] 200 | sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } 201 | wheels = [ 202 | { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, 203 | ] 204 | 205 | [[package]] 206 | name = "pydantic" 207 | version = "2.10.6" 208 | source = { registry = "https://pypi.org/simple" } 209 | dependencies = [ 210 | { name = "annotated-types" }, 211 | { name = "pydantic-core" }, 212 | { name = "typing-extensions" }, 213 | ] 214 | sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } 215 | wheels = [ 216 | { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, 217 | ] 218 | 219 | [[package]] 220 | name = "pydantic-core" 221 | version = "2.27.2" 222 | source = { registry = "https://pypi.org/simple" } 223 | dependencies = [ 224 | { name = "typing-extensions" }, 225 | ] 226 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 227 | wheels = [ 228 | { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, 229 | { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, 230 | { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, 231 | { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, 232 | { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, 233 | { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, 234 | { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, 235 | { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, 236 | { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, 237 | { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, 238 | { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, 239 | { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, 240 | { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, 241 | { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, 242 | { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, 243 | { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, 244 | { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, 245 | { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, 246 | { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, 247 | { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, 248 | { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, 249 | { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, 250 | { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, 251 | { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, 252 | { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, 253 | { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, 254 | { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, 255 | { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, 256 | { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, 257 | { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, 258 | { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, 259 | { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, 260 | { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, 261 | { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, 262 | { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, 263 | { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, 264 | { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, 265 | { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, 266 | { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, 267 | { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, 268 | { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, 269 | { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, 270 | { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, 271 | { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, 272 | { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, 273 | { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, 274 | { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, 275 | { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, 276 | { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, 277 | { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, 278 | { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, 279 | { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, 280 | { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, 281 | { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, 282 | { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, 283 | { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, 284 | { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, 285 | { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, 286 | { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, 287 | { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, 288 | { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, 289 | { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, 290 | { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, 291 | { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, 292 | ] 293 | 294 | [[package]] 295 | name = "pydantic-settings" 296 | version = "2.7.1" 297 | source = { registry = "https://pypi.org/simple" } 298 | dependencies = [ 299 | { name = "pydantic" }, 300 | { name = "python-dotenv" }, 301 | ] 302 | sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } 303 | wheels = [ 304 | { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, 305 | ] 306 | 307 | [[package]] 308 | name = "pygments" 309 | version = "2.19.1" 310 | source = { registry = "https://pypi.org/simple" } 311 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 312 | wheels = [ 313 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 314 | ] 315 | 316 | [[package]] 317 | name = "python-dotenv" 318 | version = "1.0.1" 319 | source = { registry = "https://pypi.org/simple" } 320 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 321 | wheels = [ 322 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 323 | ] 324 | 325 | [[package]] 326 | name = "rich" 327 | version = "13.9.4" 328 | source = { registry = "https://pypi.org/simple" } 329 | dependencies = [ 330 | { name = "markdown-it-py" }, 331 | { name = "pygments" }, 332 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 333 | ] 334 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 335 | wheels = [ 336 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 337 | ] 338 | 339 | [[package]] 340 | name = "shellingham" 341 | version = "1.5.4" 342 | source = { registry = "https://pypi.org/simple" } 343 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 344 | wheels = [ 345 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 346 | ] 347 | 348 | [[package]] 349 | name = "sniffio" 350 | version = "1.3.1" 351 | source = { registry = "https://pypi.org/simple" } 352 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 353 | wheels = [ 354 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 355 | ] 356 | 357 | [[package]] 358 | name = "sse-starlette" 359 | version = "2.2.1" 360 | source = { registry = "https://pypi.org/simple" } 361 | dependencies = [ 362 | { name = "anyio" }, 363 | { name = "starlette" }, 364 | ] 365 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 366 | wheels = [ 367 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 368 | ] 369 | 370 | [[package]] 371 | name = "starlette" 372 | version = "0.45.3" 373 | source = { registry = "https://pypi.org/simple" } 374 | dependencies = [ 375 | { name = "anyio" }, 376 | ] 377 | sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 } 378 | wheels = [ 379 | { url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 }, 380 | ] 381 | 382 | [[package]] 383 | name = "typer" 384 | version = "0.15.1" 385 | source = { registry = "https://pypi.org/simple" } 386 | dependencies = [ 387 | { name = "click" }, 388 | { name = "rich" }, 389 | { name = "shellingham" }, 390 | { name = "typing-extensions" }, 391 | ] 392 | sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } 393 | wheels = [ 394 | { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, 395 | ] 396 | 397 | [[package]] 398 | name = "typing-extensions" 399 | version = "4.12.2" 400 | source = { registry = "https://pypi.org/simple" } 401 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 402 | wheels = [ 403 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 404 | ] 405 | 406 | [[package]] 407 | name = "uvicorn" 408 | version = "0.34.0" 409 | source = { registry = "https://pypi.org/simple" } 410 | dependencies = [ 411 | { name = "click" }, 412 | { name = "h11" }, 413 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 414 | ] 415 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 416 | wheels = [ 417 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 418 | ] 419 | --------------------------------------------------------------------------------