mirror of
https://github.com/haris-musa/excel-mcp-server.git
synced 2025-12-08 17:12:41 +08:00
Initial commit
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Build and Distribution
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
src/*.egg-info/
|
||||||
|
|
||||||
|
# Development Environment
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.cursor/
|
||||||
|
.cursorignore
|
||||||
|
.cursorrules
|
||||||
|
.specstory
|
||||||
|
|
||||||
|
# Testing and Linting
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
htmlcov/
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Project Files
|
||||||
|
extras/
|
||||||
|
.notes/
|
||||||
|
logs/
|
||||||
|
output/
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
*.log
|
||||||
|
excel_files/
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
104
README.md
Normal file
104
README.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Excel MCP Server
|
||||||
|
|
||||||
|
A Model Context Protocol (MCP) server implementation that provides Excel file manipulation capabilities without requiring Microsoft Excel installation. This server enables workbook creation, data manipulation, formatting, and advanced Excel features.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- MCP SDK 1.2.0+
|
||||||
|
- OpenPyXL 3.1.2+
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
The server provides Excel workbook manipulation through OpenPyXL:
|
||||||
|
|
||||||
|
- Creates and modifies Excel workbooks
|
||||||
|
- Manages worksheets and ranges
|
||||||
|
- Handles formatting and styles
|
||||||
|
- Supports charts and pivot tables
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
This server provides a comprehensive set of Excel manipulation tools. For detailed documentation of all available tools, their parameters, and usage examples, please refer to [TOOLS.md](TOOLS.md).
|
||||||
|
|
||||||
|
The tools include capabilities for:
|
||||||
|
|
||||||
|
- Workbook and worksheet management
|
||||||
|
- Data reading and writing
|
||||||
|
- Formatting and styling
|
||||||
|
- Charts and visualizations
|
||||||
|
- Pivot tables and data analysis
|
||||||
|
|
||||||
|
See [TOOLS.md](TOOLS.md) for complete documentation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Full Excel Support: Comprehensive Excel functionality
|
||||||
|
- Data Manipulation: Read, write, and transform data
|
||||||
|
- Advanced Features: Charts, pivot tables, and formatting
|
||||||
|
- Error Handling: Comprehensive error handling with clear messages
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
The server can be configured using the following environment variables:
|
||||||
|
|
||||||
|
- `EXCEL_FILES_PATH`: Directory where Excel files will be stored (default: `./excel_files`)
|
||||||
|
|
||||||
|
You can set this in different ways:
|
||||||
|
|
||||||
|
Windows CMD:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
set EXCEL_FILES_PATH=C:\path\to\excel\files
|
||||||
|
uv run excel-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:EXCEL_FILES_PATH="C:\path\to\excel\files"
|
||||||
|
uv run excel-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/MacOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EXCEL_FILES_PATH=/path/to/excel/files
|
||||||
|
uv run excel-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in Claude Desktop config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"excel": {
|
||||||
|
"command": "uv run excel-mcp-server",
|
||||||
|
"transport": "sse",
|
||||||
|
"env": {
|
||||||
|
"EXCEL_FILES_PATH": "/path/to/excel/files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Starting the Server
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run excel-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start in SSE mode and wait for connections from MCP clients.
|
||||||
|
|
||||||
|
For available tools and their usage, please refer to [TOOLS.md](TOOLS.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
|
||||||
341
TOOLS.md
Normal file
341
TOOLS.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
# Excel MCP Server Tools
|
||||||
|
|
||||||
|
This document provides detailed information about all available tools in the Excel MCP server.
|
||||||
|
|
||||||
|
## Workbook Operations
|
||||||
|
|
||||||
|
### create_workbook
|
||||||
|
|
||||||
|
Creates a new Excel workbook.
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_workbook(filepath: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path where to create workbook
|
||||||
|
- Returns: Success message with created file path
|
||||||
|
|
||||||
|
### create_worksheet
|
||||||
|
|
||||||
|
Creates a new worksheet in an existing workbook.
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_worksheet(filepath: str, sheet_name: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Name for the new worksheet
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### get_workbook_metadata
|
||||||
|
|
||||||
|
Get metadata about workbook including sheets and ranges.
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_workbook_metadata(filepath: str, include_ranges: bool = False) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `include_ranges`: Whether to include range information
|
||||||
|
- Returns: String representation of workbook metadata
|
||||||
|
|
||||||
|
## Data Operations
|
||||||
|
|
||||||
|
### write_data_to_excel
|
||||||
|
|
||||||
|
Write data to Excel worksheet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
write_data_to_excel(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data: List[Dict],
|
||||||
|
start_cell: str = "A1",
|
||||||
|
write_headers: bool = True
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `data`: List of dictionaries containing data to write
|
||||||
|
- `start_cell`: Starting cell (default: "A1")
|
||||||
|
- `write_headers`: Whether to write dictionary keys as headers
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### read_data_from_excel
|
||||||
|
|
||||||
|
Read data from Excel worksheet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
read_data_from_excel(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str = "A1",
|
||||||
|
end_cell: str = None,
|
||||||
|
preview_only: bool = False
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Source worksheet name
|
||||||
|
- `start_cell`: Starting cell (default: "A1")
|
||||||
|
- `end_cell`: Optional ending cell
|
||||||
|
- `preview_only`: Whether to return only a preview
|
||||||
|
- Returns: String representation of data
|
||||||
|
|
||||||
|
## Formatting Operations
|
||||||
|
|
||||||
|
### format_range
|
||||||
|
|
||||||
|
Apply formatting to a range of cells.
|
||||||
|
|
||||||
|
```python
|
||||||
|
format_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str = None,
|
||||||
|
bold: bool = False,
|
||||||
|
italic: bool = False,
|
||||||
|
underline: bool = False,
|
||||||
|
font_size: int = None,
|
||||||
|
font_color: str = None,
|
||||||
|
bg_color: str = None,
|
||||||
|
border_style: str = None,
|
||||||
|
border_color: str = None,
|
||||||
|
number_format: str = None,
|
||||||
|
alignment: str = None,
|
||||||
|
wrap_text: bool = False,
|
||||||
|
merge_cells: bool = False,
|
||||||
|
protection: Dict[str, Any] = None,
|
||||||
|
conditional_format: Dict[str, Any] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `start_cell`: Starting cell of range
|
||||||
|
- `end_cell`: Optional ending cell of range
|
||||||
|
- Various formatting options (see parameters)
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### merge_cells
|
||||||
|
|
||||||
|
Merge a range of cells.
|
||||||
|
|
||||||
|
```python
|
||||||
|
merge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `start_cell`: Starting cell of range
|
||||||
|
- `end_cell`: Ending cell of range
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### unmerge_cells
|
||||||
|
|
||||||
|
Unmerge a previously merged range of cells.
|
||||||
|
|
||||||
|
```python
|
||||||
|
unmerge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `start_cell`: Starting cell of range
|
||||||
|
- `end_cell`: Ending cell of range
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
## Formula Operations
|
||||||
|
|
||||||
|
### apply_formula
|
||||||
|
|
||||||
|
Apply Excel formula to cell.
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_formula(filepath: str, sheet_name: str, cell: str, formula: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `cell`: Target cell reference
|
||||||
|
- `formula`: Excel formula to apply
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### validate_formula_syntax
|
||||||
|
|
||||||
|
Validate Excel formula syntax without applying it.
|
||||||
|
|
||||||
|
```python
|
||||||
|
validate_formula_syntax(filepath: str, sheet_name: str, cell: str, formula: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `cell`: Target cell reference
|
||||||
|
- `formula`: Excel formula to validate
|
||||||
|
- Returns: Validation result message
|
||||||
|
|
||||||
|
## Chart Operations
|
||||||
|
|
||||||
|
### create_chart
|
||||||
|
|
||||||
|
Create chart in worksheet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_chart(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
chart_type: str,
|
||||||
|
target_cell: str,
|
||||||
|
title: str = "",
|
||||||
|
x_axis: str = "",
|
||||||
|
y_axis: str = ""
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `data_range`: Range containing chart data
|
||||||
|
- `chart_type`: Type of chart (line, bar, pie, scatter, area)
|
||||||
|
- `target_cell`: Cell where to place chart
|
||||||
|
- `title`: Optional chart title
|
||||||
|
- `x_axis`: Optional X-axis label
|
||||||
|
- `y_axis`: Optional Y-axis label
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
## Pivot Table Operations
|
||||||
|
|
||||||
|
### create_pivot_table
|
||||||
|
|
||||||
|
Create pivot table in worksheet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_pivot_table(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
target_cell: str,
|
||||||
|
rows: List[str],
|
||||||
|
values: List[str],
|
||||||
|
columns: List[str] = None,
|
||||||
|
agg_func: str = "mean"
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `data_range`: Range containing source data
|
||||||
|
- `target_cell`: Cell where to place pivot table
|
||||||
|
- `rows`: Fields for row labels
|
||||||
|
- `values`: Fields for values
|
||||||
|
- `columns`: Optional fields for column labels
|
||||||
|
- `agg_func`: Aggregation function (sum, count, average, max, min)
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
## Worksheet Operations
|
||||||
|
|
||||||
|
### copy_worksheet
|
||||||
|
|
||||||
|
Copy worksheet within workbook.
|
||||||
|
|
||||||
|
```python
|
||||||
|
copy_worksheet(filepath: str, source_sheet: str, target_sheet: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `source_sheet`: Name of sheet to copy
|
||||||
|
- `target_sheet`: Name for new sheet
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### delete_worksheet
|
||||||
|
|
||||||
|
Delete worksheet from workbook.
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_worksheet(filepath: str, sheet_name: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Name of sheet to delete
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### rename_worksheet
|
||||||
|
|
||||||
|
Rename worksheet in workbook.
|
||||||
|
|
||||||
|
```python
|
||||||
|
rename_worksheet(filepath: str, old_name: str, new_name: str) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `old_name`: Current sheet name
|
||||||
|
- `new_name`: New sheet name
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
## Range Operations
|
||||||
|
|
||||||
|
### copy_range
|
||||||
|
|
||||||
|
Copy a range of cells to another location.
|
||||||
|
|
||||||
|
```python
|
||||||
|
copy_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
source_start: str,
|
||||||
|
source_end: str,
|
||||||
|
target_start: str,
|
||||||
|
target_sheet: str = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Source worksheet name
|
||||||
|
- `source_start`: Starting cell of source range
|
||||||
|
- `source_end`: Ending cell of source range
|
||||||
|
- `target_start`: Starting cell for paste
|
||||||
|
- `target_sheet`: Optional target worksheet name
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### delete_range
|
||||||
|
|
||||||
|
Delete a range of cells and shift remaining cells.
|
||||||
|
|
||||||
|
```python
|
||||||
|
delete_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str,
|
||||||
|
shift_direction: str = "up"
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `start_cell`: Starting cell of range
|
||||||
|
- `end_cell`: Ending cell of range
|
||||||
|
- `shift_direction`: Direction to shift cells ("up" or "left")
|
||||||
|
- Returns: Success message
|
||||||
|
|
||||||
|
### validate_excel_range
|
||||||
|
|
||||||
|
Validate if a range exists and is properly formatted.
|
||||||
|
|
||||||
|
```python
|
||||||
|
validate_excel_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filepath`: Path to Excel file
|
||||||
|
- `sheet_name`: Target worksheet name
|
||||||
|
- `start_cell`: Starting cell of range
|
||||||
|
- `end_cell`: Optional ending cell of range
|
||||||
|
- Returns: Validation result message
|
||||||
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "excel-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for Excel file manipulation"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"mcp[cli]>=1.2.0",
|
||||||
|
"openpyxl>=3.1.2"
|
||||||
|
]
|
||||||
|
[[project.authors]]
|
||||||
|
name = "haris"
|
||||||
|
email = "haris.musa@outlook.com"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
excel-mcp-server = "excel_mcp.__main__:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/excel_mcp"]
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
packages = ["src/excel_mcp"]
|
||||||
21
src/excel_mcp/__main__.py
Normal file
21
src/excel_mcp/__main__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import asyncio
|
||||||
|
from .server import run_server
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Start the Excel MCP server."""
|
||||||
|
try:
|
||||||
|
print("Excel MCP Server")
|
||||||
|
print("---------------")
|
||||||
|
print("Starting server... Press Ctrl+C to exit")
|
||||||
|
asyncio.run(run_server())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down server...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
print("Server stopped.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
60
src/excel_mcp/calculations.py
Normal file
60
src/excel_mcp/calculations.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from typing import Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .workbook import get_or_create_workbook
|
||||||
|
from .cell_utils import validate_cell_reference
|
||||||
|
from .exceptions import ValidationError, CalculationError
|
||||||
|
from .validation import validate_formula
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def apply_formula(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
cell: str,
|
||||||
|
formula: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply any Excel formula to a cell."""
|
||||||
|
try:
|
||||||
|
if not validate_cell_reference(cell):
|
||||||
|
raise ValidationError(f"Invalid cell reference: {cell}")
|
||||||
|
|
||||||
|
wb = get_or_create_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
sheet = wb[sheet_name]
|
||||||
|
|
||||||
|
# Ensure formula starts with =
|
||||||
|
if not formula.startswith('='):
|
||||||
|
formula = f'={formula}'
|
||||||
|
|
||||||
|
# Validate formula syntax
|
||||||
|
is_valid, message = validate_formula(formula)
|
||||||
|
if not is_valid:
|
||||||
|
raise CalculationError(f"Invalid formula syntax: {message}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Apply formula to the cell
|
||||||
|
cell_obj = sheet[cell]
|
||||||
|
cell_obj.value = formula
|
||||||
|
except Exception as e:
|
||||||
|
raise CalculationError(f"Failed to apply formula to cell: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb.save(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
raise CalculationError(f"Failed to save workbook after applying formula: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Applied formula '{formula}' to cell {cell}",
|
||||||
|
"cell": cell,
|
||||||
|
"formula": formula
|
||||||
|
}
|
||||||
|
|
||||||
|
except (ValidationError, CalculationError) as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply formula: {e}")
|
||||||
|
raise CalculationError(str(e))
|
||||||
54
src/excel_mcp/cell_utils.py
Normal file
54
src/excel_mcp/cell_utils.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
def parse_cell_range(
|
||||||
|
cell_ref: str,
|
||||||
|
end_ref: str | None = None
|
||||||
|
) -> tuple[int, int, int | None, int | None]:
|
||||||
|
"""Parse Excel cell reference into row and column indices."""
|
||||||
|
if end_ref:
|
||||||
|
start_cell = cell_ref
|
||||||
|
end_cell = end_ref
|
||||||
|
else:
|
||||||
|
start_cell = cell_ref
|
||||||
|
end_cell = None
|
||||||
|
|
||||||
|
match = re.match(r"([A-Z]+)([0-9]+)", start_cell.upper())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid cell reference: {start_cell}")
|
||||||
|
col_str, row_str = match.groups()
|
||||||
|
start_row = int(row_str)
|
||||||
|
start_col = column_index_from_string(col_str)
|
||||||
|
|
||||||
|
if end_cell:
|
||||||
|
match = re.match(r"([A-Z]+)([0-9]+)", end_cell.upper())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid cell reference: {end_cell}")
|
||||||
|
col_str, row_str = match.groups()
|
||||||
|
end_row = int(row_str)
|
||||||
|
end_col = column_index_from_string(col_str)
|
||||||
|
else:
|
||||||
|
end_row = None
|
||||||
|
end_col = None
|
||||||
|
|
||||||
|
return start_row, start_col, end_row, end_col
|
||||||
|
|
||||||
|
def validate_cell_reference(cell_ref: str) -> bool:
|
||||||
|
"""Validate Excel cell reference format (e.g., 'A1', 'BC123')"""
|
||||||
|
if not cell_ref:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Split into column and row parts
|
||||||
|
col = row = ""
|
||||||
|
for c in cell_ref:
|
||||||
|
if c.isalpha():
|
||||||
|
if row: # Letters after numbers not allowed
|
||||||
|
return False
|
||||||
|
col += c
|
||||||
|
elif c.isdigit():
|
||||||
|
row += c
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(col and row)
|
||||||
237
src/excel_mcp/chart.py
Normal file
237
src/excel_mcp/chart.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
from typing import Any, Optional, Dict
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.chart import (
|
||||||
|
BarChart, LineChart, PieChart, ScatterChart,
|
||||||
|
AreaChart, Reference, Series
|
||||||
|
)
|
||||||
|
from openpyxl.chart.label import DataLabelList
|
||||||
|
from openpyxl.chart.legend import Legend
|
||||||
|
from openpyxl.chart.axis import ChartLines
|
||||||
|
from openpyxl.drawing.spreadsheet_drawing import (
|
||||||
|
AnchorMarker, OneCellAnchor, SpreadsheetDrawing
|
||||||
|
)
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
from .cell_utils import parse_cell_range
|
||||||
|
from .exceptions import ValidationError, ChartError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ChartType(str, Enum):
|
||||||
|
"""Supported chart types"""
|
||||||
|
LINE = "line"
|
||||||
|
BAR = "bar"
|
||||||
|
PIE = "pie"
|
||||||
|
SCATTER = "scatter"
|
||||||
|
AREA = "area"
|
||||||
|
BUBBLE = "bubble"
|
||||||
|
STOCK = "stock"
|
||||||
|
SURFACE = "surface"
|
||||||
|
RADAR = "radar"
|
||||||
|
|
||||||
|
class ChartStyle:
|
||||||
|
"""Chart style configuration"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title_size: int = 14,
|
||||||
|
title_bold: bool = True,
|
||||||
|
axis_label_size: int = 12,
|
||||||
|
show_legend: bool = True,
|
||||||
|
legend_position: str = "r",
|
||||||
|
show_data_labels: bool = True,
|
||||||
|
grid_lines: bool = False,
|
||||||
|
style_id: int = 2
|
||||||
|
):
|
||||||
|
self.title_size = title_size
|
||||||
|
self.title_bold = title_bold
|
||||||
|
self.axis_label_size = axis_label_size
|
||||||
|
self.show_legend = show_legend
|
||||||
|
self.legend_position = legend_position
|
||||||
|
self.show_data_labels = show_data_labels
|
||||||
|
self.grid_lines = grid_lines
|
||||||
|
self.style_id = style_id
|
||||||
|
|
||||||
|
def create_chart_in_sheet(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
chart_type: str,
|
||||||
|
target_cell: str,
|
||||||
|
title: str = "",
|
||||||
|
x_axis: str = "",
|
||||||
|
y_axis: str = "",
|
||||||
|
style: Optional[Dict] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create chart in sheet with enhanced styling options"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
logger.error(f"Sheet '{sheet_name}' not found")
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
worksheet = wb[sheet_name]
|
||||||
|
|
||||||
|
# Initialize collections if they don't exist
|
||||||
|
if not hasattr(worksheet, '_drawings'):
|
||||||
|
worksheet._drawings = []
|
||||||
|
if not hasattr(worksheet, '_charts'):
|
||||||
|
worksheet._charts = []
|
||||||
|
|
||||||
|
# Parse the data range
|
||||||
|
if "!" in data_range:
|
||||||
|
range_sheet_name, cell_range = data_range.split("!")
|
||||||
|
if range_sheet_name not in wb.sheetnames:
|
||||||
|
logger.error(f"Sheet '{range_sheet_name}' referenced in data range not found")
|
||||||
|
raise ValidationError(f"Sheet '{range_sheet_name}' referenced in data range not found")
|
||||||
|
else:
|
||||||
|
cell_range = data_range
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_cell, end_cell = cell_range.split(":")
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid data range format: {e}")
|
||||||
|
raise ValidationError(f"Invalid data range format: {str(e)}")
|
||||||
|
|
||||||
|
# Validate chart type
|
||||||
|
chart_classes = {
|
||||||
|
"line": LineChart,
|
||||||
|
"bar": BarChart,
|
||||||
|
"pie": PieChart,
|
||||||
|
"scatter": ScatterChart,
|
||||||
|
"area": AreaChart
|
||||||
|
}
|
||||||
|
|
||||||
|
chart_type_lower = chart_type.lower()
|
||||||
|
ChartClass = chart_classes.get(chart_type_lower)
|
||||||
|
if not ChartClass:
|
||||||
|
logger.error(f"Unsupported chart type: {chart_type}")
|
||||||
|
raise ValidationError(
|
||||||
|
f"Unsupported chart type: {chart_type}. "
|
||||||
|
f"Supported types: {', '.join(chart_classes.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
chart = ChartClass()
|
||||||
|
|
||||||
|
# Basic chart settings
|
||||||
|
chart.title = title
|
||||||
|
if hasattr(chart, "x_axis"):
|
||||||
|
chart.x_axis.title = x_axis
|
||||||
|
if hasattr(chart, "y_axis"):
|
||||||
|
chart.y_axis.title = y_axis
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create data references
|
||||||
|
if chart_type_lower == "scatter":
|
||||||
|
# For scatter charts, create series for each pair of columns
|
||||||
|
for col in range(start_col + 1, end_col + 1):
|
||||||
|
x_values = Reference(
|
||||||
|
worksheet,
|
||||||
|
min_row=start_row + 1,
|
||||||
|
max_row=end_row,
|
||||||
|
min_col=start_col
|
||||||
|
)
|
||||||
|
y_values = Reference(
|
||||||
|
worksheet,
|
||||||
|
min_row=start_row + 1,
|
||||||
|
max_row=end_row,
|
||||||
|
min_col=col
|
||||||
|
)
|
||||||
|
series = Series(y_values, x_values, title_from_data=True)
|
||||||
|
chart.series.append(series)
|
||||||
|
else:
|
||||||
|
# For other chart types
|
||||||
|
data = Reference(
|
||||||
|
worksheet,
|
||||||
|
min_row=start_row,
|
||||||
|
max_row=end_row,
|
||||||
|
min_col=start_col + 1,
|
||||||
|
max_col=end_col
|
||||||
|
)
|
||||||
|
cats = Reference(
|
||||||
|
worksheet,
|
||||||
|
min_row=start_row + 1,
|
||||||
|
max_row=end_row,
|
||||||
|
min_col=start_col
|
||||||
|
)
|
||||||
|
chart.add_data(data, titles_from_data=True)
|
||||||
|
chart.set_categories(cats)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create chart data references: {e}")
|
||||||
|
raise ChartError(f"Failed to create chart data references: {str(e)}")
|
||||||
|
|
||||||
|
# Apply style if provided
|
||||||
|
try:
|
||||||
|
if style:
|
||||||
|
if style.get("show_legend", True):
|
||||||
|
chart.legend = Legend()
|
||||||
|
chart.legend.position = style.get("legend_position", "r")
|
||||||
|
else:
|
||||||
|
chart.legend = None
|
||||||
|
|
||||||
|
if style.get("show_data_labels", False):
|
||||||
|
chart.dataLabels = DataLabelList()
|
||||||
|
chart.dataLabels.showVal = True
|
||||||
|
|
||||||
|
if style.get("grid_lines", False):
|
||||||
|
if hasattr(chart, "x_axis"):
|
||||||
|
chart.x_axis.majorGridlines = ChartLines()
|
||||||
|
if hasattr(chart, "y_axis"):
|
||||||
|
chart.y_axis.majorGridlines = ChartLines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply chart style: {e}")
|
||||||
|
raise ChartError(f"Failed to apply chart style: {str(e)}")
|
||||||
|
|
||||||
|
# Set chart size
|
||||||
|
chart.width = 15
|
||||||
|
chart.height = 7.5
|
||||||
|
|
||||||
|
# Create drawing and anchor
|
||||||
|
try:
|
||||||
|
drawing = SpreadsheetDrawing()
|
||||||
|
drawing.chart = chart
|
||||||
|
|
||||||
|
# Validate target cell format
|
||||||
|
if not target_cell or not any(c.isalpha() for c in target_cell) or not any(c.isdigit() for c in target_cell):
|
||||||
|
raise ValidationError(f"Invalid target cell format: {target_cell}")
|
||||||
|
|
||||||
|
# Create anchor
|
||||||
|
col = column_index_from_string(target_cell[0]) - 1
|
||||||
|
row = int(target_cell[1:]) - 1
|
||||||
|
anchor = OneCellAnchor()
|
||||||
|
anchor._from = AnchorMarker(col=col, row=row)
|
||||||
|
drawing.anchor = anchor
|
||||||
|
|
||||||
|
# Add to worksheet
|
||||||
|
worksheet._drawings.append(drawing)
|
||||||
|
worksheet._charts.append(chart)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Invalid target cell: {e}")
|
||||||
|
raise ValidationError(f"Invalid target cell: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create chart drawing: {e}")
|
||||||
|
raise ChartError(f"Failed to create chart drawing: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb.save(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save workbook: {e}")
|
||||||
|
raise ChartError(f"Failed to save workbook with chart: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"{chart_type.capitalize()} chart created successfully",
|
||||||
|
"details": {
|
||||||
|
"type": chart_type,
|
||||||
|
"location": target_cell,
|
||||||
|
"data_range": data_range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except (ValidationError, ChartError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error creating chart: {e}")
|
||||||
|
raise ChartError(f"Unexpected error creating chart: {str(e)}")
|
||||||
199
src/excel_mcp/data.py
Normal file
199
src/excel_mcp/data.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from .exceptions import DataError
|
||||||
|
from .cell_utils import parse_cell_range
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def read_excel_range(
|
||||||
|
filepath: Path | str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str = "A1",
|
||||||
|
end_cell: str | None = None,
|
||||||
|
preview_only: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Read data from Excel range with optional preview mode"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath, read_only=True)
|
||||||
|
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise DataError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
|
||||||
|
# Parse start cell
|
||||||
|
if ':' in start_cell:
|
||||||
|
start_cell, end_cell = start_cell.split(':')
|
||||||
|
|
||||||
|
# Get start coordinates
|
||||||
|
try:
|
||||||
|
start_coords = parse_cell_range(f"{start_cell}:{start_cell}")
|
||||||
|
if not start_coords or not all(coord is not None for coord in start_coords[:2]):
|
||||||
|
raise DataError(f"Invalid start cell reference: {start_cell}")
|
||||||
|
start_row, start_col = start_coords[0], start_coords[1]
|
||||||
|
except ValueError as e:
|
||||||
|
raise DataError(f"Invalid start cell format: {str(e)}")
|
||||||
|
|
||||||
|
# Determine end coordinates
|
||||||
|
if end_cell:
|
||||||
|
try:
|
||||||
|
end_coords = parse_cell_range(f"{end_cell}:{end_cell}")
|
||||||
|
if not end_coords or not all(coord is not None for coord in end_coords[:2]):
|
||||||
|
raise DataError(f"Invalid end cell reference: {end_cell}")
|
||||||
|
end_row, end_col = end_coords[0], end_coords[1]
|
||||||
|
except ValueError as e:
|
||||||
|
raise DataError(f"Invalid end cell format: {str(e)}")
|
||||||
|
else:
|
||||||
|
# For single cell, use same coordinates
|
||||||
|
end_row, end_col = start_row, start_col
|
||||||
|
|
||||||
|
# Validate range bounds
|
||||||
|
if start_row > ws.max_row or start_col > ws.max_column:
|
||||||
|
raise DataError(
|
||||||
|
f"Start cell out of bounds. Sheet dimensions are "
|
||||||
|
f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
# If it's a single cell or single row, just read the values directly
|
||||||
|
if start_row == end_row:
|
||||||
|
row_data = {}
|
||||||
|
for col in range(start_col, end_col + 1):
|
||||||
|
cell = ws.cell(row=start_row, column=col)
|
||||||
|
col_name = f"Column_{col}"
|
||||||
|
row_data[col_name] = cell.value
|
||||||
|
if any(v is not None for v in row_data.values()):
|
||||||
|
data.append(row_data)
|
||||||
|
else:
|
||||||
|
# Multiple rows - use header row
|
||||||
|
headers = []
|
||||||
|
for col in range(start_col, end_col + 1):
|
||||||
|
cell_value = ws.cell(row=start_row, column=col).value
|
||||||
|
headers.append(str(cell_value) if cell_value is not None else f"Column_{col}")
|
||||||
|
|
||||||
|
# Get data rows
|
||||||
|
max_rows = min(start_row + 5, end_row) if preview_only else end_row
|
||||||
|
for row in range(start_row + 1, max_rows + 1):
|
||||||
|
row_data = {}
|
||||||
|
for col, header in enumerate(headers, start=start_col):
|
||||||
|
cell = ws.cell(row=row, column=col)
|
||||||
|
row_data[header] = cell.value
|
||||||
|
if any(v is not None for v in row_data.values()):
|
||||||
|
data.append(row_data)
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return data
|
||||||
|
except DataError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read Excel range: {e}")
|
||||||
|
raise DataError(str(e))
|
||||||
|
|
||||||
|
def write_data(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str | None,
|
||||||
|
data: list[dict[str, Any]] | None,
|
||||||
|
start_cell: str = "A1",
|
||||||
|
write_headers: bool = True,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Write data to Excel sheet with workbook handling"""
|
||||||
|
try:
|
||||||
|
if not data:
|
||||||
|
raise DataError("No data provided to write")
|
||||||
|
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
|
||||||
|
# If no sheet specified, use active sheet
|
||||||
|
if not sheet_name:
|
||||||
|
sheet_name = wb.active.title
|
||||||
|
elif sheet_name not in wb.sheetnames:
|
||||||
|
wb.create_sheet(sheet_name)
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
|
||||||
|
# Validate start cell
|
||||||
|
try:
|
||||||
|
start_coords = parse_cell_range(start_cell)
|
||||||
|
if not start_coords or not all(coord is not None for coord in start_coords[:2]):
|
||||||
|
raise DataError(f"Invalid start cell reference: {start_cell}")
|
||||||
|
except ValueError as e:
|
||||||
|
raise DataError(f"Invalid start cell format: {str(e)}")
|
||||||
|
|
||||||
|
if len(data) > 0:
|
||||||
|
# Check if first row of data contains headers
|
||||||
|
first_row = data[0]
|
||||||
|
has_headers = all(
|
||||||
|
isinstance(value, str) and value.strip() == key.strip()
|
||||||
|
for key, value in first_row.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
# If first row contains headers, skip it when write_headers is True
|
||||||
|
if has_headers and write_headers:
|
||||||
|
data = data[1:]
|
||||||
|
|
||||||
|
_write_data_to_worksheet(ws, data, start_cell, write_headers)
|
||||||
|
|
||||||
|
wb.save(filepath)
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
return {"message": f"Data written to {sheet_name}", "active_sheet": sheet_name}
|
||||||
|
except DataError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write data: {e}")
|
||||||
|
raise DataError(str(e))
|
||||||
|
|
||||||
|
def _write_data_to_worksheet(
|
||||||
|
worksheet: Worksheet,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
start_cell: str = "A1",
|
||||||
|
write_headers: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Write data to worksheet - internal helper function"""
|
||||||
|
try:
|
||||||
|
if not data:
|
||||||
|
raise DataError("No data provided to write")
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_coords = parse_cell_range(start_cell)
|
||||||
|
if not start_coords or not all(x is not None for x in start_coords[:2]):
|
||||||
|
raise DataError(f"Invalid start cell reference: {start_cell}")
|
||||||
|
start_row, start_col = start_coords[0], start_coords[1]
|
||||||
|
except ValueError as e:
|
||||||
|
raise DataError(f"Invalid start cell format: {str(e)}")
|
||||||
|
|
||||||
|
# Validate data structure
|
||||||
|
if not all(isinstance(row, dict) for row in data):
|
||||||
|
raise DataError("All data rows must be dictionaries")
|
||||||
|
|
||||||
|
# Write headers if requested
|
||||||
|
headers = list(data[0].keys())
|
||||||
|
if write_headers:
|
||||||
|
for i, header in enumerate(headers):
|
||||||
|
cell = worksheet.cell(row=start_row, column=start_col + i)
|
||||||
|
cell.value = header
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
start_row += 1 # Move start row down if headers were written
|
||||||
|
|
||||||
|
# Write data
|
||||||
|
for i, row_dict in enumerate(data):
|
||||||
|
if not all(h in row_dict for h in headers):
|
||||||
|
raise DataError(f"Row {i+1} is missing required headers")
|
||||||
|
for j, header in enumerate(headers):
|
||||||
|
cell = worksheet.cell(row=start_row + i, column=start_col + j)
|
||||||
|
cell.value = row_dict.get(header, "")
|
||||||
|
except DataError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write worksheet data: {e}")
|
||||||
|
raise DataError(str(e))
|
||||||
35
src/excel_mcp/exceptions.py
Normal file
35
src/excel_mcp/exceptions.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
class ExcelMCPError(Exception):
|
||||||
|
"""Base exception for Excel MCP errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class WorkbookError(ExcelMCPError):
|
||||||
|
"""Raised when workbook operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SheetError(ExcelMCPError):
|
||||||
|
"""Raised when sheet operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DataError(ExcelMCPError):
|
||||||
|
"""Raised when data operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ValidationError(ExcelMCPError):
|
||||||
|
"""Raised when validation fails."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FormattingError(ExcelMCPError):
|
||||||
|
"""Raised when formatting operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CalculationError(ExcelMCPError):
|
||||||
|
"""Raised when formula calculations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class PivotError(ExcelMCPError):
|
||||||
|
"""Raised when pivot table operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ChartError(ExcelMCPError):
|
||||||
|
"""Raised when chart operations fail."""
|
||||||
|
pass
|
||||||
249
src/excel_mcp/formatting.py
Normal file
249
src/excel_mcp/formatting.py
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from openpyxl.styles import (
|
||||||
|
PatternFill, Border, Side, Alignment, Protection, Font,
|
||||||
|
Color
|
||||||
|
)
|
||||||
|
from openpyxl.formatting.rule import (
|
||||||
|
ColorScaleRule, DataBarRule, IconSetRule,
|
||||||
|
FormulaRule, CellIsRule
|
||||||
|
)
|
||||||
|
|
||||||
|
from .workbook import get_or_create_workbook
|
||||||
|
from .cell_utils import parse_cell_range, validate_cell_reference
|
||||||
|
from .exceptions import ValidationError, FormattingError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def format_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str = None,
|
||||||
|
bold: bool = False,
|
||||||
|
italic: bool = False,
|
||||||
|
underline: bool = False,
|
||||||
|
font_size: int = None,
|
||||||
|
font_color: str = None,
|
||||||
|
bg_color: str = None,
|
||||||
|
border_style: str = None,
|
||||||
|
border_color: str = None,
|
||||||
|
number_format: str = None,
|
||||||
|
alignment: str = None,
|
||||||
|
wrap_text: bool = False,
|
||||||
|
merge_cells: bool = False,
|
||||||
|
protection: Dict[str, Any] = None,
|
||||||
|
conditional_format: Dict[str, Any] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Apply formatting to a range of cells.
|
||||||
|
|
||||||
|
This function handles all Excel formatting operations including:
|
||||||
|
- Font properties (bold, italic, size, color, etc.)
|
||||||
|
- Cell fill/background color
|
||||||
|
- Borders (style and color)
|
||||||
|
- Number formatting
|
||||||
|
- Alignment and text wrapping
|
||||||
|
- Cell merging
|
||||||
|
- Protection
|
||||||
|
- Conditional formatting
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to Excel file
|
||||||
|
sheet_name: Name of worksheet
|
||||||
|
start_cell: Starting cell reference
|
||||||
|
end_cell: Optional ending cell reference
|
||||||
|
bold: Whether to make text bold
|
||||||
|
italic: Whether to make text italic
|
||||||
|
underline: Whether to underline text
|
||||||
|
font_size: Font size in points
|
||||||
|
font_color: Font color (hex code)
|
||||||
|
bg_color: Background color (hex code)
|
||||||
|
border_style: Border style (thin, medium, thick, double)
|
||||||
|
border_color: Border color (hex code)
|
||||||
|
number_format: Excel number format string
|
||||||
|
alignment: Text alignment (left, center, right, justify)
|
||||||
|
wrap_text: Whether to wrap text
|
||||||
|
merge_cells: Whether to merge the range
|
||||||
|
protection: Cell protection settings
|
||||||
|
conditional_format: Conditional formatting rules
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate cell references
|
||||||
|
if not validate_cell_reference(start_cell):
|
||||||
|
raise ValidationError(f"Invalid start cell reference: {start_cell}")
|
||||||
|
|
||||||
|
if end_cell and not validate_cell_reference(end_cell):
|
||||||
|
raise ValidationError(f"Invalid end cell reference: {end_cell}")
|
||||||
|
|
||||||
|
wb = get_or_create_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
sheet = wb[sheet_name]
|
||||||
|
|
||||||
|
# Get cell range coordinates
|
||||||
|
try:
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(f"Invalid cell range: {str(e)}")
|
||||||
|
|
||||||
|
# If no end cell specified, use start cell coordinates
|
||||||
|
if end_row is None:
|
||||||
|
end_row = start_row
|
||||||
|
if end_col is None:
|
||||||
|
end_col = start_col
|
||||||
|
|
||||||
|
# Apply font formatting
|
||||||
|
font_args = {
|
||||||
|
"bold": bold,
|
||||||
|
"italic": italic,
|
||||||
|
"underline": 'single' if underline else None,
|
||||||
|
}
|
||||||
|
if font_size is not None:
|
||||||
|
font_args["size"] = font_size
|
||||||
|
if font_color is not None:
|
||||||
|
try:
|
||||||
|
# Ensure color has FF prefix for full opacity
|
||||||
|
font_color = font_color if font_color.startswith('FF') else f'FF{font_color}'
|
||||||
|
font_args["color"] = Color(rgb=font_color)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid font color: {str(e)}")
|
||||||
|
font = Font(**font_args)
|
||||||
|
|
||||||
|
# Apply fill
|
||||||
|
fill = None
|
||||||
|
if bg_color is not None:
|
||||||
|
try:
|
||||||
|
# Ensure color has FF prefix for full opacity
|
||||||
|
bg_color = bg_color if bg_color.startswith('FF') else f'FF{bg_color}'
|
||||||
|
fill = PatternFill(
|
||||||
|
start_color=Color(rgb=bg_color),
|
||||||
|
end_color=Color(rgb=bg_color),
|
||||||
|
fill_type='solid'
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid background color: {str(e)}")
|
||||||
|
|
||||||
|
# Apply borders
|
||||||
|
border = None
|
||||||
|
if border_style is not None:
|
||||||
|
try:
|
||||||
|
border_color = border_color if border_color else "000000"
|
||||||
|
border_color = border_color if border_color.startswith('FF') else f'FF{border_color}'
|
||||||
|
side = Side(
|
||||||
|
style=border_style,
|
||||||
|
color=Color(rgb=border_color)
|
||||||
|
)
|
||||||
|
border = Border(
|
||||||
|
left=side,
|
||||||
|
right=side,
|
||||||
|
top=side,
|
||||||
|
bottom=side
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid border settings: {str(e)}")
|
||||||
|
|
||||||
|
# Apply alignment
|
||||||
|
align = None
|
||||||
|
if alignment is not None or wrap_text:
|
||||||
|
try:
|
||||||
|
align = Alignment(
|
||||||
|
horizontal=alignment,
|
||||||
|
vertical='center',
|
||||||
|
wrap_text=wrap_text
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid alignment settings: {str(e)}")
|
||||||
|
|
||||||
|
# Apply protection
|
||||||
|
protect = None
|
||||||
|
if protection is not None:
|
||||||
|
try:
|
||||||
|
protect = Protection(**protection)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid protection settings: {str(e)}")
|
||||||
|
|
||||||
|
# Apply formatting to range
|
||||||
|
for row in range(start_row, end_row + 1):
|
||||||
|
for col in range(start_col, end_col + 1):
|
||||||
|
cell = sheet.cell(row=row, column=col)
|
||||||
|
cell.font = font
|
||||||
|
if fill is not None:
|
||||||
|
cell.fill = fill
|
||||||
|
if border is not None:
|
||||||
|
cell.border = border
|
||||||
|
if align is not None:
|
||||||
|
cell.alignment = align
|
||||||
|
if protect is not None:
|
||||||
|
cell.protection = protect
|
||||||
|
if number_format is not None:
|
||||||
|
cell.number_format = number_format
|
||||||
|
|
||||||
|
# Merge cells if requested
|
||||||
|
if merge_cells and end_cell:
|
||||||
|
try:
|
||||||
|
range_str = f"{start_cell}:{end_cell}"
|
||||||
|
sheet.merge_cells(range_str)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Failed to merge cells: {str(e)}")
|
||||||
|
|
||||||
|
# Apply conditional formatting
|
||||||
|
if conditional_format is not None:
|
||||||
|
range_str = f"{start_cell}:{end_cell}" if end_cell else start_cell
|
||||||
|
rule_type = conditional_format.get('type')
|
||||||
|
if not rule_type:
|
||||||
|
raise FormattingError("Conditional format type not specified")
|
||||||
|
|
||||||
|
params = conditional_format.get('params', {})
|
||||||
|
|
||||||
|
# Handle fill parameter for cell_is rule
|
||||||
|
if rule_type == 'cell_is' and 'fill' in params:
|
||||||
|
fill_params = params['fill']
|
||||||
|
if isinstance(fill_params, dict):
|
||||||
|
try:
|
||||||
|
fill_color = fill_params.get('fgColor', 'FFC7CE') # Default to light red
|
||||||
|
fill_color = fill_color if fill_color.startswith('FF') else f'FF{fill_color}'
|
||||||
|
params['fill'] = PatternFill(
|
||||||
|
start_color=fill_color,
|
||||||
|
end_color=fill_color,
|
||||||
|
fill_type='solid'
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise FormattingError(f"Invalid conditional format fill color: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if rule_type == 'color_scale':
|
||||||
|
rule = ColorScaleRule(**params)
|
||||||
|
elif rule_type == 'data_bar':
|
||||||
|
rule = DataBarRule(**params)
|
||||||
|
elif rule_type == 'icon_set':
|
||||||
|
rule = IconSetRule(**params)
|
||||||
|
elif rule_type == 'formula':
|
||||||
|
rule = FormulaRule(**params)
|
||||||
|
elif rule_type == 'cell_is':
|
||||||
|
rule = CellIsRule(**params)
|
||||||
|
else:
|
||||||
|
raise FormattingError(f"Invalid conditional format type: {rule_type}")
|
||||||
|
|
||||||
|
sheet.conditional_formatting.add(range_str, rule)
|
||||||
|
except Exception as e:
|
||||||
|
raise FormattingError(f"Failed to apply conditional formatting: {str(e)}")
|
||||||
|
|
||||||
|
wb.save(filepath)
|
||||||
|
|
||||||
|
range_str = f"{start_cell}:{end_cell}" if end_cell else start_cell
|
||||||
|
return {
|
||||||
|
"message": f"Applied formatting to range {range_str}",
|
||||||
|
"range": range_str
|
||||||
|
}
|
||||||
|
|
||||||
|
except (ValidationError, FormattingError) as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply formatting: {e}")
|
||||||
|
raise FormattingError(str(e))
|
||||||
271
src/excel_mcp/pivot.py
Normal file
271
src/excel_mcp/pivot.py
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
|
||||||
|
from .data import read_excel_range
|
||||||
|
from .cell_utils import parse_cell_range
|
||||||
|
from .exceptions import ValidationError, PivotError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def create_pivot_table(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
rows: list[str],
|
||||||
|
values: list[str],
|
||||||
|
columns: list[str] | None = None,
|
||||||
|
agg_func: str = "sum"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create pivot table in sheet using Excel table functionality
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to Excel file
|
||||||
|
sheet_name: Name of worksheet containing source data
|
||||||
|
data_range: Source data range reference
|
||||||
|
target_cell: Cell reference for pivot table position
|
||||||
|
rows: Fields for row labels
|
||||||
|
values: Fields for values
|
||||||
|
columns: Optional fields for column labels
|
||||||
|
agg_func: Aggregation function (sum, count, average, max, min)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message and pivot table dimensions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
# Parse ranges
|
||||||
|
if ':' not in data_range:
|
||||||
|
raise ValidationError("Data range must be in format 'A1:B2'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_cell, end_cell = data_range.split(':')
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(f"Invalid data range format: {str(e)}")
|
||||||
|
|
||||||
|
if end_row is None or end_col is None:
|
||||||
|
raise ValidationError("Invalid data range format: missing end coordinates")
|
||||||
|
|
||||||
|
# Create range string
|
||||||
|
data_range_str = f"{get_column_letter(start_col)}{start_row}:{get_column_letter(end_col)}{end_row}"
|
||||||
|
|
||||||
|
# Read source data
|
||||||
|
try:
|
||||||
|
data = read_excel_range(filepath, sheet_name, start_cell, end_cell)
|
||||||
|
if not data:
|
||||||
|
raise PivotError("No data found in range")
|
||||||
|
except Exception as e:
|
||||||
|
raise PivotError(f"Failed to read source data: {str(e)}")
|
||||||
|
|
||||||
|
# Validate aggregation function
|
||||||
|
valid_agg_funcs = ["sum", "average", "count", "min", "max"]
|
||||||
|
if agg_func.lower() not in valid_agg_funcs:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Invalid aggregation function. Must be one of: {', '.join(valid_agg_funcs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up field names by removing aggregation suffixes
|
||||||
|
def clean_field_name(field: str) -> str:
|
||||||
|
field = str(field).strip()
|
||||||
|
for suffix in [" (sum)", " (average)", " (count)", " (min)", " (max)"]:
|
||||||
|
if field.lower().endswith(suffix):
|
||||||
|
return field[:-len(suffix)]
|
||||||
|
return field
|
||||||
|
|
||||||
|
# Validate field names exist in data
|
||||||
|
if data:
|
||||||
|
first_row = data[0]
|
||||||
|
available_fields = {clean_field_name(str(header)).lower() for header in first_row.keys()}
|
||||||
|
|
||||||
|
for field_list, field_type in [(rows, "row"), (values, "value")]:
|
||||||
|
for field in field_list:
|
||||||
|
if clean_field_name(str(field)).lower() not in available_fields:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Invalid {field_type} field '{field}'. "
|
||||||
|
f"Available fields: {', '.join(sorted(available_fields))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if columns:
|
||||||
|
for field in columns:
|
||||||
|
if clean_field_name(str(field)).lower() not in available_fields:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Invalid column field '{field}'. "
|
||||||
|
f"Available fields: {', '.join(sorted(available_fields))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip header row if it matches our fields
|
||||||
|
if all(
|
||||||
|
any(clean_field_name(str(header)).lower() == clean_field_name(str(field)).lower()
|
||||||
|
for field in rows + values)
|
||||||
|
for header in first_row.keys()
|
||||||
|
):
|
||||||
|
data = data[1:]
|
||||||
|
|
||||||
|
# Clean up row and value field names
|
||||||
|
cleaned_rows = [clean_field_name(field) for field in rows]
|
||||||
|
cleaned_values = [clean_field_name(field) for field in values]
|
||||||
|
|
||||||
|
# Create pivot sheet
|
||||||
|
pivot_sheet_name = f"{sheet_name}_pivot"
|
||||||
|
if pivot_sheet_name in wb.sheetnames:
|
||||||
|
wb.remove(wb[pivot_sheet_name])
|
||||||
|
pivot_ws = wb.create_sheet(pivot_sheet_name)
|
||||||
|
|
||||||
|
# Write headers
|
||||||
|
current_row = 1
|
||||||
|
current_col = 1
|
||||||
|
|
||||||
|
# Write row field headers
|
||||||
|
for field in cleaned_rows:
|
||||||
|
cell = pivot_ws.cell(row=current_row, column=current_col, value=field)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
current_col += 1
|
||||||
|
|
||||||
|
# Write value field headers
|
||||||
|
for field in cleaned_values:
|
||||||
|
cell = pivot_ws.cell(row=current_row, column=current_col, value=f"{field} ({agg_func})")
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
current_col += 1
|
||||||
|
|
||||||
|
# Get unique values for each row field
|
||||||
|
field_values = {}
|
||||||
|
for field in cleaned_rows:
|
||||||
|
all_values = []
|
||||||
|
for record in data:
|
||||||
|
value = str(record.get(field, ''))
|
||||||
|
all_values.append(value)
|
||||||
|
field_values[field] = sorted(set(all_values))
|
||||||
|
|
||||||
|
# Generate all combinations of row field values
|
||||||
|
row_combinations = _get_combinations(field_values)
|
||||||
|
|
||||||
|
# Calculate table dimensions for formatting
|
||||||
|
total_rows = len(row_combinations) + 1 # +1 for header
|
||||||
|
total_cols = len(cleaned_rows) + len(cleaned_values)
|
||||||
|
|
||||||
|
# Write data rows
|
||||||
|
current_row = 2
|
||||||
|
for combo in row_combinations:
|
||||||
|
# Write row field values
|
||||||
|
col = 1
|
||||||
|
for field in cleaned_rows:
|
||||||
|
pivot_ws.cell(row=current_row, column=col, value=combo[field])
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
# Filter data for current combination
|
||||||
|
filtered_data = _filter_data(data, combo, {})
|
||||||
|
|
||||||
|
# Calculate and write aggregated values
|
||||||
|
for value_field in cleaned_values:
|
||||||
|
try:
|
||||||
|
value = _aggregate_values(filtered_data, value_field, agg_func)
|
||||||
|
pivot_ws.cell(row=current_row, column=col, value=value)
|
||||||
|
except Exception as e:
|
||||||
|
raise PivotError(f"Failed to aggregate values for field '{value_field}': {str(e)}")
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Create a table for the pivot data
|
||||||
|
try:
|
||||||
|
pivot_range = f"A1:{get_column_letter(total_cols)}{total_rows}"
|
||||||
|
pivot_table = Table(
|
||||||
|
displayName=f"PivotTable_{uuid.uuid4().hex[:8]}",
|
||||||
|
ref=pivot_range
|
||||||
|
)
|
||||||
|
style = TableStyleInfo(
|
||||||
|
name="TableStyleMedium9",
|
||||||
|
showFirstColumn=False,
|
||||||
|
showLastColumn=False,
|
||||||
|
showRowStripes=True,
|
||||||
|
showColumnStripes=True
|
||||||
|
)
|
||||||
|
pivot_table.tableStyleInfo = style
|
||||||
|
pivot_ws.add_table(pivot_table)
|
||||||
|
except Exception as e:
|
||||||
|
raise PivotError(f"Failed to create pivot table formatting: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb.save(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
raise PivotError(f"Failed to save workbook: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Summary table created successfully",
|
||||||
|
"details": {
|
||||||
|
"source_range": data_range_str,
|
||||||
|
"pivot_sheet": pivot_sheet_name,
|
||||||
|
"rows": cleaned_rows,
|
||||||
|
"columns": columns or [],
|
||||||
|
"values": cleaned_values,
|
||||||
|
"aggregation": agg_func
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except (ValidationError, PivotError) as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create pivot table: {e}")
|
||||||
|
raise PivotError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_combinations(field_values: dict[str, set]) -> list[dict]:
|
||||||
|
"""Get all combinations of field values."""
|
||||||
|
result = [{}]
|
||||||
|
for field, values in list(field_values.items()): # Convert to list to avoid runtime changes
|
||||||
|
new_result = []
|
||||||
|
for combo in result:
|
||||||
|
for value in sorted(values): # Sort for consistent ordering
|
||||||
|
new_combo = combo.copy()
|
||||||
|
new_combo[field] = value
|
||||||
|
new_result.append(new_combo)
|
||||||
|
result = new_result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_data(data: list[dict], row_filters: dict, col_filters: dict) -> list[dict]:
|
||||||
|
"""Filter data based on row and column filters."""
|
||||||
|
result = []
|
||||||
|
for record in data:
|
||||||
|
matches = True
|
||||||
|
for field, value in row_filters.items():
|
||||||
|
if record.get(field) != value:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
for field, value in col_filters.items():
|
||||||
|
if record.get(field) != value:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
if matches:
|
||||||
|
result.append(record)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_values(data: list[dict], field: str, agg_func: str) -> float:
|
||||||
|
"""Aggregate values using the specified function."""
|
||||||
|
values = [record[field] for record in data if field in record and isinstance(record[field], (int, float))]
|
||||||
|
if not values:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if agg_func == "sum":
|
||||||
|
return sum(values)
|
||||||
|
elif agg_func == "average":
|
||||||
|
return sum(values) / len(values)
|
||||||
|
elif agg_func == "count":
|
||||||
|
return len(values)
|
||||||
|
elif agg_func == "min":
|
||||||
|
return min(values)
|
||||||
|
elif agg_func == "max":
|
||||||
|
return max(values)
|
||||||
|
else:
|
||||||
|
return sum(values) # Default to sum
|
||||||
491
src/excel_mcp/server.py
Normal file
491
src/excel_mcp/server.py
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import Any, List, Dict
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
# Import exceptions
|
||||||
|
from excel_mcp.exceptions import (
|
||||||
|
ValidationError,
|
||||||
|
WorkbookError,
|
||||||
|
SheetError,
|
||||||
|
DataError,
|
||||||
|
FormattingError,
|
||||||
|
CalculationError,
|
||||||
|
PivotError,
|
||||||
|
ChartError
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import from excel_mcp package with consistent _impl suffixes
|
||||||
|
from excel_mcp.validation import (
|
||||||
|
validate_formula_in_cell_operation as validate_formula_impl,
|
||||||
|
validate_range_in_sheet_operation as validate_range_impl
|
||||||
|
)
|
||||||
|
from excel_mcp.chart import create_chart_in_sheet as create_chart_impl
|
||||||
|
from excel_mcp.workbook import get_workbook_info
|
||||||
|
from excel_mcp.data import write_data
|
||||||
|
from excel_mcp.pivot import create_pivot_table as create_pivot_table_impl
|
||||||
|
from excel_mcp.sheet import (
|
||||||
|
copy_sheet,
|
||||||
|
delete_sheet,
|
||||||
|
rename_sheet,
|
||||||
|
merge_range,
|
||||||
|
unmerge_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.FileHandler("excel-mcp.log")
|
||||||
|
],
|
||||||
|
force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("excel-mcp")
|
||||||
|
|
||||||
|
# Get Excel files path from environment or use default
|
||||||
|
EXCEL_FILES_PATH = os.environ.get("EXCEL_FILES_PATH", "./excel_files")
|
||||||
|
|
||||||
|
# Create the directory if it doesn't exist
|
||||||
|
os.makedirs(EXCEL_FILES_PATH, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize FastMCP server
|
||||||
|
mcp = FastMCP(
|
||||||
|
"excel-mcp",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Excel MCP Server for manipulating Excel files",
|
||||||
|
dependencies=["openpyxl>=3.1.2"],
|
||||||
|
env_vars={
|
||||||
|
"EXCEL_FILES_PATH": {
|
||||||
|
"description": "Path to Excel files directory",
|
||||||
|
"required": False,
|
||||||
|
"default": EXCEL_FILES_PATH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_excel_path(filename: str) -> str:
|
||||||
|
"""Get full path to Excel file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Name of Excel file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full path to Excel file
|
||||||
|
"""
|
||||||
|
# If filename is already an absolute path, return it
|
||||||
|
if os.path.isabs(filename):
|
||||||
|
return filename
|
||||||
|
|
||||||
|
# Use the configured Excel files path
|
||||||
|
return os.path.join(EXCEL_FILES_PATH, filename)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def apply_formula(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
cell: str,
|
||||||
|
formula: str,
|
||||||
|
) -> str:
|
||||||
|
"""Apply Excel formula to cell."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
# First validate the formula
|
||||||
|
validation = validate_formula_impl(full_path, sheet_name, cell, formula)
|
||||||
|
if isinstance(validation, dict) and "error" in validation:
|
||||||
|
return f"Error: {validation['error']}"
|
||||||
|
|
||||||
|
# If valid, apply the formula
|
||||||
|
from excel_mcp.calculations import apply_formula as apply_formula_impl
|
||||||
|
result = apply_formula_impl(full_path, sheet_name, cell, formula)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, CalculationError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying formula: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def validate_formula_syntax(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
cell: str,
|
||||||
|
formula: str,
|
||||||
|
) -> str:
|
||||||
|
"""Validate Excel formula syntax without applying it."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = validate_formula_impl(full_path, sheet_name, cell, formula)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, CalculationError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating formula: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def format_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str = None,
|
||||||
|
bold: bool = False,
|
||||||
|
italic: bool = False,
|
||||||
|
underline: bool = False,
|
||||||
|
font_size: int = None,
|
||||||
|
font_color: str = None,
|
||||||
|
bg_color: str = None,
|
||||||
|
border_style: str = None,
|
||||||
|
border_color: str = None,
|
||||||
|
number_format: str = None,
|
||||||
|
alignment: str = None,
|
||||||
|
wrap_text: bool = False,
|
||||||
|
merge_cells: bool = False,
|
||||||
|
protection: Dict[str, Any] = None,
|
||||||
|
conditional_format: Dict[str, Any] = None
|
||||||
|
) -> str:
|
||||||
|
"""Apply formatting to a range of cells."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.formatting import format_range as format_range_func
|
||||||
|
|
||||||
|
result = format_range_func(
|
||||||
|
filepath=full_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
start_cell=start_cell,
|
||||||
|
end_cell=end_cell,
|
||||||
|
bold=bold,
|
||||||
|
italic=italic,
|
||||||
|
underline=underline,
|
||||||
|
font_size=font_size,
|
||||||
|
font_color=font_color,
|
||||||
|
bg_color=bg_color,
|
||||||
|
border_style=border_style,
|
||||||
|
border_color=border_color,
|
||||||
|
number_format=number_format,
|
||||||
|
alignment=alignment,
|
||||||
|
wrap_text=wrap_text,
|
||||||
|
merge_cells=merge_cells,
|
||||||
|
protection=protection,
|
||||||
|
conditional_format=conditional_format
|
||||||
|
)
|
||||||
|
return "Range formatted successfully"
|
||||||
|
except (ValidationError, FormattingError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error formatting range: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def read_data_from_excel(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str = "A1",
|
||||||
|
end_cell: str = None,
|
||||||
|
preview_only: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Read data from Excel worksheet."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.data import read_excel_range
|
||||||
|
result = read_excel_range(full_path, sheet_name, start_cell, end_cell, preview_only)
|
||||||
|
if not result:
|
||||||
|
return "No data found in specified range"
|
||||||
|
# Convert the list of dicts to a formatted string
|
||||||
|
data_str = "\n".join([str(row) for row in result])
|
||||||
|
return data_str
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading data: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def write_data_to_excel(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data: List[Dict],
|
||||||
|
start_cell: str = "A1",
|
||||||
|
write_headers: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Write data to Excel worksheet."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = write_data(full_path, sheet_name, data, start_cell, write_headers)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, DataError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing data: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_workbook(filepath: str) -> str:
|
||||||
|
"""Create new Excel workbook."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.workbook import create_workbook as create_workbook_impl
|
||||||
|
result = create_workbook_impl(full_path)
|
||||||
|
return f"Created workbook at {full_path}"
|
||||||
|
except WorkbookError as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating workbook: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_worksheet(filepath: str, sheet_name: str) -> str:
|
||||||
|
"""Create new worksheet in workbook."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.workbook import create_sheet as create_worksheet_impl
|
||||||
|
result = create_worksheet_impl(full_path, sheet_name)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, WorkbookError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating worksheet: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_chart(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
chart_type: str,
|
||||||
|
target_cell: str,
|
||||||
|
title: str = "",
|
||||||
|
x_axis: str = "",
|
||||||
|
y_axis: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Create chart in worksheet."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = create_chart_impl(
|
||||||
|
filepath=full_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
data_range=data_range,
|
||||||
|
chart_type=chart_type,
|
||||||
|
target_cell=target_cell,
|
||||||
|
title=title,
|
||||||
|
x_axis=x_axis,
|
||||||
|
y_axis=y_axis
|
||||||
|
)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, ChartError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating chart: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_pivot_table(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
data_range: str,
|
||||||
|
target_cell: str,
|
||||||
|
rows: List[str],
|
||||||
|
values: List[str],
|
||||||
|
columns: List[str] = None,
|
||||||
|
agg_func: str = "mean"
|
||||||
|
) -> str:
|
||||||
|
"""Create pivot table in worksheet."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = create_pivot_table_impl(
|
||||||
|
filepath=full_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
data_range=data_range,
|
||||||
|
target_cell=target_cell,
|
||||||
|
rows=rows,
|
||||||
|
values=values,
|
||||||
|
columns=columns or [],
|
||||||
|
agg_func=agg_func
|
||||||
|
)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, PivotError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating pivot table: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def copy_worksheet(
|
||||||
|
filepath: str,
|
||||||
|
source_sheet: str,
|
||||||
|
target_sheet: str
|
||||||
|
) -> str:
|
||||||
|
"""Copy worksheet within workbook."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = copy_sheet(full_path, source_sheet, target_sheet)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error copying worksheet: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def delete_worksheet(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Delete worksheet from workbook."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = delete_sheet(full_path, sheet_name)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting worksheet: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def rename_worksheet(
|
||||||
|
filepath: str,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Rename worksheet in workbook."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = rename_sheet(full_path, old_name, new_name)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error renaming worksheet: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_workbook_metadata(
|
||||||
|
filepath: str,
|
||||||
|
include_ranges: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Get metadata about workbook including sheets, ranges, etc."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = get_workbook_info(full_path, include_ranges=include_ranges)
|
||||||
|
return str(result)
|
||||||
|
except WorkbookError as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting workbook metadata: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def merge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str:
|
||||||
|
"""Merge a range of cells."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = merge_range(full_path, sheet_name, start_cell, end_cell)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error merging cells: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def unmerge_cells(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> str:
|
||||||
|
"""Unmerge a range of cells."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
result = unmerge_range(full_path, sheet_name, start_cell, end_cell)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error unmerging cells: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def copy_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
source_start: str,
|
||||||
|
source_end: str,
|
||||||
|
target_start: str,
|
||||||
|
target_sheet: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Copy a range of cells to another location."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.sheet import copy_range_operation
|
||||||
|
result = copy_range_operation(
|
||||||
|
full_path,
|
||||||
|
sheet_name,
|
||||||
|
source_start,
|
||||||
|
source_end,
|
||||||
|
target_start,
|
||||||
|
target_sheet
|
||||||
|
)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error copying range: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def delete_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str,
|
||||||
|
shift_direction: str = "up"
|
||||||
|
) -> str:
|
||||||
|
"""Delete a range of cells and shift remaining cells."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
from excel_mcp.sheet import delete_range_operation
|
||||||
|
result = delete_range_operation(
|
||||||
|
full_path,
|
||||||
|
sheet_name,
|
||||||
|
start_cell,
|
||||||
|
end_cell,
|
||||||
|
shift_direction
|
||||||
|
)
|
||||||
|
return result["message"]
|
||||||
|
except (ValidationError, SheetError) as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting range: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def validate_excel_range(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str = None
|
||||||
|
) -> str:
|
||||||
|
"""Validate if a range exists and is properly formatted."""
|
||||||
|
try:
|
||||||
|
full_path = get_excel_path(filepath)
|
||||||
|
range_str = start_cell if not end_cell else f"{start_cell}:{end_cell}"
|
||||||
|
result = validate_range_impl(full_path, sheet_name, range_str)
|
||||||
|
return result["message"]
|
||||||
|
except ValidationError as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating range: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def run_server():
|
||||||
|
"""Run the Excel MCP server."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting Excel MCP server (files directory: {EXCEL_FILES_PATH})")
|
||||||
|
await mcp.run_sse_async()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Server stopped by user")
|
||||||
|
await mcp.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Server failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info("Server shutdown complete")
|
||||||
327
src/excel_mcp/sheet.py
Normal file
327
src/excel_mcp/sheet.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.styles import Font, Border, PatternFill, Side
|
||||||
|
|
||||||
|
from .cell_utils import parse_cell_range, validate_cell_reference
|
||||||
|
from .exceptions import SheetError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def copy_sheet(filepath: str, source_sheet: str, target_sheet: str) -> dict[str, Any]:
|
||||||
|
"""Copy a worksheet within the same workbook."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if source_sheet not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Source sheet '{source_sheet}' not found")
|
||||||
|
|
||||||
|
if target_sheet in wb.sheetnames:
|
||||||
|
raise SheetError(f"Target sheet '{target_sheet}' already exists")
|
||||||
|
|
||||||
|
source = wb[source_sheet]
|
||||||
|
target = wb.copy_worksheet(source)
|
||||||
|
target.title = target_sheet
|
||||||
|
|
||||||
|
wb.save(filepath)
|
||||||
|
return {"message": f"Sheet '{source_sheet}' copied to '{target_sheet}'"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to copy sheet: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def delete_sheet(filepath: str, sheet_name: str) -> dict[str, Any]:
|
||||||
|
"""Delete a worksheet from the workbook."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
if len(wb.sheetnames) == 1:
|
||||||
|
raise SheetError("Cannot delete the only sheet in workbook")
|
||||||
|
|
||||||
|
del wb[sheet_name]
|
||||||
|
wb.save(filepath)
|
||||||
|
return {"message": f"Sheet '{sheet_name}' deleted"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete sheet: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def rename_sheet(filepath: str, old_name: str, new_name: str) -> dict[str, Any]:
|
||||||
|
"""Rename a worksheet."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if old_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{old_name}' not found")
|
||||||
|
|
||||||
|
if new_name in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{new_name}' already exists")
|
||||||
|
|
||||||
|
sheet = wb[old_name]
|
||||||
|
sheet.title = new_name
|
||||||
|
wb.save(filepath)
|
||||||
|
return {"message": f"Sheet renamed from '{old_name}' to '{new_name}'"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to rename sheet: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def format_range_string(start_row: int, start_col: int, end_row: int, end_col: int) -> str:
|
||||||
|
"""Format range string from row and column indices."""
|
||||||
|
return f"{get_column_letter(start_col)}{start_row}:{get_column_letter(end_col)}{end_row}"
|
||||||
|
|
||||||
|
def copy_range(
|
||||||
|
source_ws: Worksheet,
|
||||||
|
target_ws: Worksheet,
|
||||||
|
source_range: str,
|
||||||
|
target_start: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Copy range from source worksheet to target worksheet."""
|
||||||
|
# Parse source range
|
||||||
|
if ':' in source_range:
|
||||||
|
source_start, source_end = source_range.split(':')
|
||||||
|
else:
|
||||||
|
source_start = source_range
|
||||||
|
source_end = None
|
||||||
|
|
||||||
|
src_start_row, src_start_col, src_end_row, src_end_col = parse_cell_range(
|
||||||
|
source_start, source_end
|
||||||
|
)
|
||||||
|
|
||||||
|
if src_end_row is None:
|
||||||
|
src_end_row = src_start_row
|
||||||
|
src_end_col = src_start_col
|
||||||
|
|
||||||
|
if target_start is None:
|
||||||
|
target_start = source_start
|
||||||
|
|
||||||
|
tgt_start_row, tgt_start_col, _, _ = parse_cell_range(target_start)
|
||||||
|
|
||||||
|
for i, row in enumerate(range(src_start_row, src_end_row + 1)):
|
||||||
|
for j, col in enumerate(range(src_start_col, src_end_col + 1)):
|
||||||
|
source_cell = source_ws.cell(row=row, column=col)
|
||||||
|
target_cell = target_ws.cell(row=tgt_start_row + i, column=tgt_start_col + j)
|
||||||
|
|
||||||
|
target_cell.value = source_cell.value
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Copy font
|
||||||
|
font_kwargs = {}
|
||||||
|
if hasattr(source_cell.font, 'name'):
|
||||||
|
font_kwargs['name'] = source_cell.font.name
|
||||||
|
if hasattr(source_cell.font, 'size'):
|
||||||
|
font_kwargs['size'] = source_cell.font.size
|
||||||
|
if hasattr(source_cell.font, 'bold'):
|
||||||
|
font_kwargs['bold'] = source_cell.font.bold
|
||||||
|
if hasattr(source_cell.font, 'italic'):
|
||||||
|
font_kwargs['italic'] = source_cell.font.italic
|
||||||
|
if hasattr(source_cell.font, 'color'):
|
||||||
|
font_color = None
|
||||||
|
if source_cell.font.color:
|
||||||
|
font_color = source_cell.font.color.rgb
|
||||||
|
font_kwargs['color'] = font_color
|
||||||
|
target_cell.font = Font(**font_kwargs)
|
||||||
|
|
||||||
|
# Copy border
|
||||||
|
new_border = Border()
|
||||||
|
for side in ['left', 'right', 'top', 'bottom']:
|
||||||
|
source_side = getattr(source_cell.border, side)
|
||||||
|
if source_side and source_side.style:
|
||||||
|
side_color = source_side.color.rgb if source_side.color else None
|
||||||
|
setattr(new_border, side, Side(
|
||||||
|
style=source_side.style,
|
||||||
|
color=side_color
|
||||||
|
))
|
||||||
|
target_cell.border = new_border
|
||||||
|
|
||||||
|
# Copy fill
|
||||||
|
if hasattr(source_cell, 'fill'):
|
||||||
|
fill_kwargs = {'patternType': source_cell.fill.patternType}
|
||||||
|
if hasattr(source_cell.fill, 'fgColor') and source_cell.fill.fgColor:
|
||||||
|
fg_color = None
|
||||||
|
if hasattr(source_cell.fill.fgColor, 'rgb'):
|
||||||
|
fg_color = source_cell.fill.fgColor.rgb
|
||||||
|
fill_kwargs['fgColor'] = fg_color
|
||||||
|
if hasattr(source_cell.fill, 'bgColor') and source_cell.fill.bgColor:
|
||||||
|
bg_color = None
|
||||||
|
if hasattr(source_cell.fill.bgColor, 'rgb'):
|
||||||
|
bg_color = source_cell.fill.bgColor.rgb
|
||||||
|
fill_kwargs['bgColor'] = bg_color
|
||||||
|
target_cell.fill = PatternFill(**fill_kwargs)
|
||||||
|
|
||||||
|
# Copy number format and alignment
|
||||||
|
if source_cell.number_format:
|
||||||
|
target_cell.number_format = source_cell.number_format
|
||||||
|
if source_cell.alignment:
|
||||||
|
target_cell.alignment = source_cell.alignment
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def delete_range(worksheet: Worksheet, start_cell: str, end_cell: str | None = None) -> None:
|
||||||
|
"""Delete contents and formatting of a range."""
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
|
||||||
|
if end_row is None:
|
||||||
|
end_row = start_row
|
||||||
|
end_col = start_col
|
||||||
|
|
||||||
|
for row in range(start_row, end_row + 1):
|
||||||
|
for col in range(start_col, end_col + 1):
|
||||||
|
cell = worksheet.cell(row=row, column=col)
|
||||||
|
cell.value = None
|
||||||
|
cell.font = Font()
|
||||||
|
cell.border = Border()
|
||||||
|
cell.fill = PatternFill()
|
||||||
|
cell.number_format = "General"
|
||||||
|
cell.alignment = None
|
||||||
|
|
||||||
|
def merge_range(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> dict[str, Any]:
|
||||||
|
"""Merge a range of cells."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
|
||||||
|
if end_row is None or end_col is None:
|
||||||
|
raise SheetError("Both start and end cells must be specified for merging")
|
||||||
|
|
||||||
|
range_string = format_range_string(start_row, start_col, end_row, end_col)
|
||||||
|
worksheet = wb[sheet_name]
|
||||||
|
worksheet.merge_cells(range_string)
|
||||||
|
wb.save(filepath)
|
||||||
|
return {"message": f"Range '{range_string}' merged in sheet '{sheet_name}'"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to merge range: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def unmerge_range(filepath: str, sheet_name: str, start_cell: str, end_cell: str) -> dict[str, Any]:
|
||||||
|
"""Unmerge a range of cells."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
worksheet = wb[sheet_name]
|
||||||
|
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
|
||||||
|
if end_row is None or end_col is None:
|
||||||
|
raise SheetError("Both start and end cells must be specified for unmerging")
|
||||||
|
|
||||||
|
range_string = format_range_string(start_row, start_col, end_row, end_col)
|
||||||
|
|
||||||
|
# Check if range is actually merged
|
||||||
|
merged_ranges = worksheet.merged_cells.ranges
|
||||||
|
target_range = range_string.upper()
|
||||||
|
|
||||||
|
if not any(str(merged_range).upper() == target_range for merged_range in merged_ranges):
|
||||||
|
raise SheetError(f"Range '{range_string}' is not merged")
|
||||||
|
|
||||||
|
worksheet.unmerge_cells(range_string)
|
||||||
|
wb.save(filepath)
|
||||||
|
return {"message": f"Range '{range_string}' unmerged successfully"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to unmerge range: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def copy_range_operation(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
source_start: str,
|
||||||
|
source_end: str | None,
|
||||||
|
target_start: str,
|
||||||
|
target_sheet: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Copy a range of cells to another location."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
source_ws = wb[sheet_name]
|
||||||
|
target_ws = wb[target_sheet] if target_sheet else source_ws
|
||||||
|
|
||||||
|
if target_sheet and target_sheet not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Target sheet '{target_sheet}' not found")
|
||||||
|
|
||||||
|
source_range = f"{source_start}:{source_end}" if source_end else source_start
|
||||||
|
|
||||||
|
# Validate source range
|
||||||
|
try:
|
||||||
|
end_row, end_col = parse_cell_range(source_start, source_end)
|
||||||
|
if end_row and end_row > source_ws.max_row:
|
||||||
|
raise SheetError(f"End row {end_row} out of bounds (1-{source_ws.max_row})")
|
||||||
|
if end_col and end_col > source_ws.max_column:
|
||||||
|
raise SheetError(f"End column {end_col} out of bounds (1-{source_ws.max_column})")
|
||||||
|
except ValueError as e:
|
||||||
|
raise SheetError(f"Invalid range: {str(e)}")
|
||||||
|
|
||||||
|
# Validate target cell
|
||||||
|
try:
|
||||||
|
validate_cell_reference(target_start)
|
||||||
|
except ValueError as e:
|
||||||
|
raise SheetError(f"Invalid target cell: {str(e)}")
|
||||||
|
|
||||||
|
copy_range(source_ws, target_ws, source_range, target_start)
|
||||||
|
wb.save(filepath)
|
||||||
|
|
||||||
|
return {"message": f"Range copied successfully"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to copy range: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
|
|
||||||
|
def delete_range_operation(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a range of cells and shift remaining cells."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise SheetError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
worksheet = wb[sheet_name]
|
||||||
|
|
||||||
|
# Validate range
|
||||||
|
try:
|
||||||
|
end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
if end_row and end_row > worksheet.max_row:
|
||||||
|
raise SheetError(f"End row {end_row} out of bounds (1-{worksheet.max_row})")
|
||||||
|
if end_col and end_col > worksheet.max_column:
|
||||||
|
raise SheetError(f"End column {end_col} out of bounds (1-{worksheet.max_column})")
|
||||||
|
except ValueError as e:
|
||||||
|
raise SheetError(f"Invalid range: {str(e)}")
|
||||||
|
|
||||||
|
delete_range(worksheet, start_cell, end_cell)
|
||||||
|
wb.save(filepath)
|
||||||
|
|
||||||
|
return {"message": f"Range deleted successfully"}
|
||||||
|
except SheetError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete range: {e}")
|
||||||
|
raise SheetError(str(e))
|
||||||
235
src/excel_mcp/validation.py
Normal file
235
src/excel_mcp/validation.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
from .cell_utils import parse_cell_range, validate_cell_reference
|
||||||
|
from .exceptions import ValidationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def validate_formula_in_cell_operation(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
cell: str,
|
||||||
|
formula: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate Excel formula before writing"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
if not validate_cell_reference(cell):
|
||||||
|
raise ValidationError(f"Invalid cell reference: {cell}")
|
||||||
|
|
||||||
|
# First validate the provided formula's syntax
|
||||||
|
is_valid, message = validate_formula(formula)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValidationError(f"Invalid formula syntax: {message}")
|
||||||
|
|
||||||
|
# Additional validation for cell references in formula
|
||||||
|
cell_refs = re.findall(r'[A-Z]+[0-9]+(?::[A-Z]+[0-9]+)?', formula)
|
||||||
|
for ref in cell_refs:
|
||||||
|
if ':' in ref: # Range reference
|
||||||
|
start, end = ref.split(':')
|
||||||
|
if not (validate_cell_reference(start) and validate_cell_reference(end)):
|
||||||
|
raise ValidationError(f"Invalid cell range reference in formula: {ref}")
|
||||||
|
else: # Single cell reference
|
||||||
|
if not validate_cell_reference(ref):
|
||||||
|
raise ValidationError(f"Invalid cell reference in formula: {ref}")
|
||||||
|
|
||||||
|
# Now check if there's a formula in the cell and compare
|
||||||
|
sheet = wb[sheet_name]
|
||||||
|
cell_obj = sheet[cell]
|
||||||
|
current_formula = cell_obj.value
|
||||||
|
|
||||||
|
# If cell has a formula (starts with =)
|
||||||
|
if isinstance(current_formula, str) and current_formula.startswith('='):
|
||||||
|
if formula.startswith('='):
|
||||||
|
if current_formula != formula:
|
||||||
|
return {
|
||||||
|
"message": "Formula is valid but doesn't match cell content",
|
||||||
|
"valid": True,
|
||||||
|
"matches": False,
|
||||||
|
"cell": cell,
|
||||||
|
"provided_formula": formula,
|
||||||
|
"current_formula": current_formula
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if current_formula != f"={formula}":
|
||||||
|
return {
|
||||||
|
"message": "Formula is valid but doesn't match cell content",
|
||||||
|
"valid": True,
|
||||||
|
"matches": False,
|
||||||
|
"cell": cell,
|
||||||
|
"provided_formula": formula,
|
||||||
|
"current_formula": current_formula
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"message": "Formula is valid and matches cell content",
|
||||||
|
"valid": True,
|
||||||
|
"matches": True,
|
||||||
|
"cell": cell,
|
||||||
|
"formula": formula
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"message": "Formula is valid but cell contains no formula",
|
||||||
|
"valid": True,
|
||||||
|
"matches": False,
|
||||||
|
"cell": cell,
|
||||||
|
"provided_formula": formula,
|
||||||
|
"current_content": str(current_formula) if current_formula else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to validate formula: {e}")
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
def validate_range_in_sheet_operation(
|
||||||
|
filepath: str,
|
||||||
|
sheet_name: str,
|
||||||
|
start_cell: str,
|
||||||
|
end_cell: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate if a range exists in a worksheet and return data range info."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
raise ValidationError(f"Sheet '{sheet_name}' not found")
|
||||||
|
|
||||||
|
worksheet = wb[sheet_name]
|
||||||
|
|
||||||
|
# Get actual data dimensions
|
||||||
|
data_max_row = worksheet.max_row
|
||||||
|
data_max_col = worksheet.max_column
|
||||||
|
|
||||||
|
# Validate range
|
||||||
|
try:
|
||||||
|
start_row, start_col, end_row, end_col = parse_cell_range(start_cell, end_cell)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(f"Invalid range: {str(e)}")
|
||||||
|
|
||||||
|
# If end not specified, use start
|
||||||
|
if end_row is None:
|
||||||
|
end_row = start_row
|
||||||
|
if end_col is None:
|
||||||
|
end_col = start_col
|
||||||
|
|
||||||
|
# Validate bounds against maximum possible Excel limits
|
||||||
|
is_valid, message = validate_range_bounds(
|
||||||
|
worksheet, start_row, start_col, end_row, end_col
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValidationError(message)
|
||||||
|
|
||||||
|
range_str = f"{start_cell}" if end_cell is None else f"{start_cell}:{end_cell}"
|
||||||
|
data_range_str = f"A1:{get_column_letter(data_max_col)}{data_max_row}"
|
||||||
|
|
||||||
|
# Check if range is within data or extends beyond
|
||||||
|
extends_beyond_data = (
|
||||||
|
end_row > data_max_row or
|
||||||
|
end_col > data_max_col
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": (
|
||||||
|
f"Range '{range_str}' is valid. "
|
||||||
|
f"Sheet contains data in range '{data_range_str}'"
|
||||||
|
),
|
||||||
|
"valid": True,
|
||||||
|
"range": range_str,
|
||||||
|
"data_range": data_range_str,
|
||||||
|
"extends_beyond_data": extends_beyond_data,
|
||||||
|
"data_dimensions": {
|
||||||
|
"max_row": data_max_row,
|
||||||
|
"max_col": data_max_col,
|
||||||
|
"max_col_letter": get_column_letter(data_max_col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to validate range: {e}")
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
def validate_formula(formula: str) -> tuple[bool, str]:
|
||||||
|
"""Validate Excel formula syntax and safety"""
|
||||||
|
if not formula.startswith("="):
|
||||||
|
return False, "Formula must start with '='"
|
||||||
|
|
||||||
|
# Remove the '=' prefix for validation
|
||||||
|
formula = formula[1:]
|
||||||
|
|
||||||
|
# Check for balanced parentheses
|
||||||
|
parens = 0
|
||||||
|
for c in formula:
|
||||||
|
if c == "(":
|
||||||
|
parens += 1
|
||||||
|
elif c == ")":
|
||||||
|
parens -= 1
|
||||||
|
if parens < 0:
|
||||||
|
return False, "Unmatched closing parenthesis"
|
||||||
|
|
||||||
|
if parens > 0:
|
||||||
|
return False, "Unclosed parenthesis"
|
||||||
|
|
||||||
|
# Basic function name validation
|
||||||
|
func_pattern = r"([A-Z]+)\("
|
||||||
|
funcs = re.findall(func_pattern, formula)
|
||||||
|
unsafe_funcs = {"INDIRECT", "HYPERLINK", "WEBSERVICE", "DGET", "RTD"}
|
||||||
|
|
||||||
|
for func in funcs:
|
||||||
|
if func in unsafe_funcs:
|
||||||
|
return False, f"Unsafe function: {func}"
|
||||||
|
|
||||||
|
return True, "Formula is valid"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_range_bounds(
|
||||||
|
worksheet: Worksheet,
|
||||||
|
start_row: int,
|
||||||
|
start_col: int,
|
||||||
|
end_row: int | None = None,
|
||||||
|
end_col: int | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Validate that cell range is within worksheet bounds"""
|
||||||
|
max_row = worksheet.max_row
|
||||||
|
max_col = worksheet.max_column
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check start cell bounds
|
||||||
|
if start_row < 1 or start_row > max_row:
|
||||||
|
return False, f"Start row {start_row} out of bounds (1-{max_row})"
|
||||||
|
if start_col < 1 or start_col > max_col:
|
||||||
|
return False, (
|
||||||
|
f"Start column {get_column_letter(start_col)} "
|
||||||
|
f"out of bounds (A-{get_column_letter(max_col)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If end cell specified, check its bounds
|
||||||
|
if end_row is not None and end_col is not None:
|
||||||
|
if end_row < start_row:
|
||||||
|
return False, "End row cannot be before start row"
|
||||||
|
if end_col < start_col:
|
||||||
|
return False, "End column cannot be before start column"
|
||||||
|
if end_row > max_row:
|
||||||
|
return False, f"End row {end_row} out of bounds (1-{max_row})"
|
||||||
|
if end_col > max_col:
|
||||||
|
return False, (
|
||||||
|
f"End column {get_column_letter(end_col)} "
|
||||||
|
f"out of bounds (A-{get_column_letter(max_col)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, "Range is valid"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Invalid range: {e!s}"
|
||||||
96
src/excel_mcp/workbook.py
Normal file
96
src/excel_mcp/workbook.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from .exceptions import WorkbookError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def create_workbook(filepath: str, sheet_name: str = "Sheet1") -> dict[str, Any]:
|
||||||
|
"""Create a new Excel workbook with optional custom sheet name"""
|
||||||
|
try:
|
||||||
|
wb = Workbook()
|
||||||
|
# Rename default sheet
|
||||||
|
if "Sheet" in wb.sheetnames:
|
||||||
|
sheet = wb["Sheet"]
|
||||||
|
sheet.title = sheet_name
|
||||||
|
else:
|
||||||
|
wb.create_sheet(sheet_name)
|
||||||
|
|
||||||
|
path = Path(filepath)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
wb.save(str(path))
|
||||||
|
return {
|
||||||
|
"message": f"Created workbook: {filepath}",
|
||||||
|
"active_sheet": sheet_name,
|
||||||
|
"workbook": wb
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create workbook: {e}")
|
||||||
|
raise WorkbookError(f"Failed to create workbook: {e!s}")
|
||||||
|
|
||||||
|
def get_or_create_workbook(filepath: str) -> Workbook:
|
||||||
|
"""Get existing workbook or create new one if it doesn't exist"""
|
||||||
|
try:
|
||||||
|
return load_workbook(filepath)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return create_workbook(filepath)["workbook"]
|
||||||
|
|
||||||
|
def create_sheet(filepath: str, sheet_name: str) -> dict:
|
||||||
|
"""Create a new worksheet in the workbook if it doesn't exist."""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
|
||||||
|
# Check if sheet already exists
|
||||||
|
if sheet_name in wb.sheetnames:
|
||||||
|
raise WorkbookError(f"Sheet {sheet_name} already exists")
|
||||||
|
|
||||||
|
# Create new sheet
|
||||||
|
wb.create_sheet(sheet_name)
|
||||||
|
wb.save(filepath)
|
||||||
|
wb.close()
|
||||||
|
return {"message": f"Sheet {sheet_name} created successfully"}
|
||||||
|
except WorkbookError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create sheet: {e}")
|
||||||
|
raise WorkbookError(str(e))
|
||||||
|
|
||||||
|
def get_workbook_info(filepath: str, include_ranges: bool = False) -> dict[str, Any]:
|
||||||
|
"""Get metadata about workbook including sheets, ranges, etc."""
|
||||||
|
try:
|
||||||
|
path = Path(filepath)
|
||||||
|
if not path.exists():
|
||||||
|
raise WorkbookError(f"File not found: {filepath}")
|
||||||
|
|
||||||
|
wb = load_workbook(filepath, read_only=True)
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"filename": path.name,
|
||||||
|
"sheets": wb.sheetnames,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"modified": path.stat().st_mtime
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_ranges:
|
||||||
|
# Add used ranges for each sheet
|
||||||
|
ranges = {}
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
if ws.max_row > 0 and ws.max_column > 0:
|
||||||
|
ranges[sheet_name] = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
|
||||||
|
info["used_ranges"] = ranges
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return info
|
||||||
|
|
||||||
|
except WorkbookError as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get workbook info: {e}")
|
||||||
|
raise WorkbookError(str(e))
|
||||||
417
uv.lock
generated
Normal file
417
uv.lock
generated
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
version = 1
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.1.31"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "excel-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mcp", extra = ["cli"] },
|
||||||
|
{ name = "openpyxl" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "mcp", extras = ["cli"], specifier = ">=1.2.0" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx-sse"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "httpx-sse" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "sse-starlette" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/30/51e4555826126e3954fa2ab1e934bf74163c5fe05e98f38ca4d0f8abbf63/mcp-1.2.1.tar.gz", hash = "sha256:c9d43dbfe943aa1530e2be8f54b73af3ebfb071243827b4483d421684806cb45", size = 103968 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/0d/6770742a84c8aa1d36c0d628896a380584c5759612e66af7446af07d8775/mcp-1.2.1-py3-none-any.whl", hash = "sha256:579bf9c9157850ebb1344f3ca6f7a3021b0123c44c9f089ef577a7062522f0fd", size = 66453 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
cli = [
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typer" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.10.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.27.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
|
||||||
|
wheels = [
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "13.9.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shellingham"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sniffio"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sse-starlette"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.45.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typer"
|
||||||
|
version = "0.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "shellingham" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.12.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user