diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c2f1aa8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + +- [ ] Describe the scope and motivation. +- [ ] Link related issue/spec (if any). + +## Acceptance Criteria (MCP UX Hardening) + +- [ ] AC-01: 入力互換(`name`, `col/row`, `width/height`) +- [ ] AC-02: HEX自由指定(6/8桁, `#`有無) +- [ ] AC-03: 色概念分離(`color` と `fill_color`) +- [ ] AC-04: `draw_grid_border` shorthand(`range`) +- [ ] AC-05: path UX(root基準解決と診断) +- [ ] AC-06: 後方互換(既存成功ケース維持) + +## Validation + +- [ ] `uv run task precommit-run` +- [ ] Added/updated tests for changed behavior. +- [ ] Updated docs (`docs/mcp.md`, `docs/README.ja.md`, release notes). diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 90d045e..203d5ef 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -31,14 +31,14 @@ jobs: - name: Run tests (non-COM suite) if: runner.os != 'Windows' run: | - pytest -m "not com and not render" --maxfail=1 --disable-warnings -q --cov=exstruct --cov-report=xml + pytest -m "not com and not render" --maxfail=1 --disable-warnings -q --cov=exstruct --cov-report=xml --cov-fail-under=80 - name: Run tests (full suite with skips) if: runner.os == 'Windows' env: SKIP_COM_TESTS: "1" RUN_RENDER_SMOKE: "0" # enable with 1 when Excel+pypdfium2 are available run: | - pytest -m "not com and not render" --maxfail=1 --disable-warnings -q --cov=exstruct --cov-report=xml + pytest -m "not com and not render" --maxfail=1 --disable-warnings -q --cov=exstruct --cov-report=xml --cov-fail-under=80 - name: Upload coverage to Codecov if: runner.os == 'Linux' && matrix.python-version == '3.12' uses: codecov/codecov-action@125fc84a9a348dbcf27191600683ec096ec9021c # v4.x.x diff --git a/.gitignore b/.gitignore index d2c544b..8608471 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,8 @@ mypy_report.txt coverage.xml errors.txt htmlcov/ -.tmp_mcp_test/ \ No newline at end of file +.tmp_mcp_test/ +sample.md +review.md + +.cocoindex_code/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bae0a5..bbd00c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,26 @@ All notable changes to this project are documented in this file. This changelog ### Added -- _No unreleased changes yet._ +- No notable changes yet. + +## [0.5.0] - 2026-02-24 + +### Added + +- Added MCP `exstruct_make` for one-call workbook creation plus `ops` apply (`out_path` required, `ops` optional), including `.xlsx`/`.xlsm`/`.xls` support and `.xls` COM constraints. +- Expanded MCP `exstruct_patch` with design editing operations: `draw_grid_border`, `set_bold`, `set_font_size`, `set_font_color`, `set_fill_color`, `set_dimensions`, `auto_fit_columns`, `merge_cells`, `unmerge_cells`, `set_alignment`, `set_style`, `apply_table_style`, and inverse restore op `restore_design_snapshot`. +- Added MCP operation schema discovery tools: `exstruct_list_ops` and `exstruct_describe_op`. +- Added MCP runtime diagnostics tool: `exstruct_get_runtime_info`. +- Added top-level `sheet` fallback for `exstruct_patch`/`exstruct_make` (non-`add_sheet` ops), with `op.sheet` precedence when both are provided. +- Added artifact mirroring support via `mirror_artifact` and server `--artifact-bridge-dir`. + +### Changed + +- Updated patch backend controls for MCP `exstruct_patch`/`exstruct_make`: `backend` input (`auto`/`com`/`openpyxl`) and `engine` output (`com`/`openpyxl`). +- Updated patch backend policy: `auto` now prefers COM when available, with controlled fallback to openpyxl for `.xlsx`/`.xlsm` when COM execution fails. +- Updated `apply_table_style` behavior: when `backend="com"` is requested, execution falls back to openpyxl with a warning. +- Refactored MCP patch internals into layered modules (`patch.service` / `patch.engine.*` / `patch.ops.*` / `patch.runtime`) while keeping tool interfaces stable. +- Updated MCP docs/README pages to include `exstruct_make` behavior and constraints. ## [0.4.4] - 2026-02-16 diff --git a/README.ja.md b/README.ja.md index 8074cfa..fcd8995 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,7 @@ # ExStruct — Excel 構造化抽出エンジン -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) ![GitHub Repo stars](https://img.shields.io/github/stars/harumiWeb/exstruct) + ![ExStruct Image](docs/assets/icon.webp) @@ -89,6 +90,8 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re 利用可能なツール: - `exstruct_extract` +- `exstruct_make` +- `exstruct_patch` - `exstruct_read_json_chunk` - `exstruct_validate_input` @@ -96,6 +99,15 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re - 標準入出力の応答を汚染しないよう、ログは標準エラー出力(およびオプションで`--log-file`で指定したファイル)に出力されます。 - WindowsのExcel環境では、標準/詳細モードでCOMを利用して、よりリッチな抽出が可能です。Windows以外ではCOMは利用できず、抽出はopenpyxlベースのフォールバック機能を使用します。 +- `exstruct_patch` は `backend` 指定をサポートします。 + - `auto`(既定): COM が使える場合は COM を優先し、不可なら openpyxl + - `com`: COM を強制(`dry_run` / `return_inverse_ops` / `preflight_formula_check` は指定不可) + - `openpyxl`: openpyxl を強制(`.xls` は非対応) +- `exstruct_patch` の応答には実際に使われたバックエンドを示す `engine`(`com` / `openpyxl`)が含まれます。`restore_design_snapshot` は引き続き openpyxl 専用です。 +- `exstruct_make` は新規ブック作成と `ops` 適用を1回で実行します(`out_path` 必須、`ops` は任意)。 + - 対応拡張子: `.xlsx` / `.xlsm` / `.xls` + - 初期シート名は `Sheet1` に正規化されます + - `.xls` は COM 必須で、`backend=openpyxl` は指定できません 各AIエージェントでのMCP設定ガイド: diff --git a/README.md b/README.md index ae4357f..753a779 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # ExStruct — Excel Structured Extraction Engine -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) ![GitHub Repo stars](https://img.shields.io/github/stars/harumiWeb/exstruct) + ![ExStruct Image](docs/assets/icon.webp) @@ -92,8 +93,12 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re Available tools: - `exstruct_extract` +- `exstruct_make` - `exstruct_patch` - `exstruct_read_json_chunk` +- `exstruct_read_range` +- `exstruct_read_cells` +- `exstruct_read_formulas` - `exstruct_validate_input` Notes: @@ -101,6 +106,15 @@ Notes: - In MCP, `exstruct_extract` defaults to `options.alpha_col=true` (column keys: `A`, `B`, ...). Set `options.alpha_col=false` for legacy 0-based numeric string keys. - Logs go to stderr (and optionally `--log-file`) to avoid contaminating stdio responses. - On Windows with Excel, standard/verbose can use COM for richer extraction. On non-Windows, COM is unavailable and extraction uses openpyxl-based fallbacks. +- `exstruct_patch` supports `backend` selection: + - `auto` (default): prefer COM when available, otherwise openpyxl + - `com`: force COM (rejects `dry_run` / `return_inverse_ops` / `preflight_formula_check`) + - `openpyxl`: force openpyxl (`.xls` is not supported) +- `exstruct_patch` response includes `engine` (`com` or `openpyxl`) to show the actual backend used. `restore_design_snapshot` remains openpyxl-only. +- `exstruct_make` creates a new workbook and applies `ops` in one call (`out_path` required, `ops` optional). + - Supports `.xlsx` / `.xlsm` / `.xls` + - Initial sheet is normalized to `Sheet1` + - `.xls` requires COM and rejects `backend=openpyxl` MCP Setup Guide for Each AI Agent: diff --git a/codecov.yml b/codecov.yml index 4b9abbd..1656df3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,11 +2,11 @@ coverage: status: project: default: - target: auto + target: 80% threshold: 1% patch: default: - target: auto + target: 80% threshold: 1% ignore: - "benchmark/**" diff --git a/docs/README.en.md b/docs/README.en.md index 741b5cf..689d164 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -1,6 +1,7 @@ # ExStruct — Excel Structured Extraction Engine -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) ![GitHub Repo stars](https://img.shields.io/github/stars/harumiWeb/exstruct) + ![ExStruct Image](assets/icon.webp) @@ -92,8 +93,12 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re Available tools: - `exstruct_extract` +- `exstruct_make` - `exstruct_patch` - `exstruct_read_json_chunk` +- `exstruct_read_range` +- `exstruct_read_cells` +- `exstruct_read_formulas` - `exstruct_validate_input` Notes: @@ -101,6 +106,10 @@ Notes: - In MCP, `exstruct_extract` defaults to `options.alpha_col=true` (column keys: `A`, `B`, ...). Set `options.alpha_col=false` for legacy 0-based numeric string keys. - Logs go to stderr (and optionally `--log-file`) to avoid contaminating stdio responses. - On Windows with Excel, standard/verbose can use COM for richer extraction. On non-Windows, COM is unavailable and extraction uses openpyxl-based fallbacks. +- `exstruct_make` creates a new workbook and applies `ops` in one call (`out_path` required, `ops` optional). + - Supports `.xlsx` / `.xlsm` / `.xls` + - Initial sheet is normalized to `Sheet1` + - `.xls` requires COM and rejects `backend=openpyxl` MCP Setup Guide for Each AI Agent: diff --git a/docs/README.ja.md b/docs/README.ja.md index ac01981..a94d872 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,8 +1,9 @@ # ExStruct — Excel 構造化抽出エンジン -[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) +[![PyPI version](https://badge.fury.io/py/exstruct.svg)](https://pypi.org/project/exstruct/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/exstruct?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/exstruct) ![Licence: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square) [![pytest](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml/badge.svg)](https://github.com/harumiWeb/exstruct/actions/workflows/pytest.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e081cb4f634e4175b259eb7c34f54f60)](https://app.codacy.com/gh/harumiWeb/exstruct/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![codecov](https://codecov.io/gh/harumiWeb/exstruct/graph/badge.svg?token=2XI1O8TTA9)](https://codecov.io/gh/harumiWeb/exstruct) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/harumiWeb/exstruct) ![GitHub Repo stars](https://img.shields.io/github/stars/harumiWeb/exstruct) -![ExStruct Image](/assets/icon.webp) + +![ExStruct Image](assets/icon.webp) ExStruct は Excel ワークブックを読み取り、構造化データ(セル・テーブル候補・図形・チャート・SmartArt・印刷範囲ビュー)をデフォルトで JSON に出力します。必要に応じて YAML/TOON も選択でき、COM/Excel 環境ではリッチ抽出、非 COM 環境ではセル+テーブル候補+印刷範囲へのフォールバックで安全に動作します。LLM/RAG 向けに検出ヒューリスティックや出力モードを調整可能です。 @@ -73,6 +74,7 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re 利用可能なツール: - `exstruct_extract` +- `exstruct_make` - `exstruct_patch` - `exstruct_read_json_chunk` - `exstruct_read_range` @@ -82,6 +84,10 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re - `exstruct_read_range` / `exstruct_read_cells` / `exstruct_read_formulas` は v0.4.4 で追加され、MCPサーバー実装とテストに登録済みです。 - MCPでは `exstruct_extract` の `options.alpha_col=true` が既定です(列キーは `A`, `B`, ...)。従来の0始まり数値キーが必要な場合は `options.alpha_col=false` を指定してください。 +- `exstruct_make` は新規ブック作成と `ops` 適用を1回で実行します(`out_path` 必須、`ops` は任意)。 + - 対応拡張子: `.xlsx` / `.xlsm` / `.xls` + - 初期シート名は `Sheet1` に正規化されます + - `.xls` は COM 必須で、`backend=openpyxl` は指定できません 注意点: @@ -651,3 +657,9 @@ BSD-3-Clause. See `LICENSE` for details. - API リファレンス (GitHub Pages): https://harumiweb.github.io/exstruct/ - JSON Schema は `schemas/` にモデルごとに配置しています。モデル変更後は `python scripts/gen_json_schema.py` で再生成してください。 + +## MCP追加メモ(UX Hardening) + +- `exstruct_get_runtime_info` を追加しました。`root` / `cwd` / `platform` / `path_examples` を1回で確認できます。 +- `exstruct_make` の相対 `out_path` は MCP の `--root` 基準で解決されます。 +- 色指定は `color`(文字色)と `fill_color`(背景色)を分離し、`RRGGBB` / `AARRGGBB`(`#` あり・なし)を受け付けます。 diff --git a/docs/agents/ARCHITECTURE.md b/docs/agents/ARCHITECTURE.md index 5630fc8..c71227b 100644 --- a/docs/agents/ARCHITECTURE.md +++ b/docs/agents/ARCHITECTURE.md @@ -71,6 +71,21 @@ PDF/PNG 出力(RAG 用途) CLI エントリポイント +### mcp/patch(Patch 実装) + +Patch 系は `src/exstruct/mcp/patch/` に責務分離して実装する。 + +- `patch_runner.py` → 互換性維持用ファサード(既存 import 経路を維持) +- `patch/internal.py` → patch 実装の内部互換レイヤ(非公開) +- `patch/service.py` → `run_patch` / `run_make` のオーケストレーション +- `patch/runtime.py` → path/backend 選択など実行時ユーティリティ集約 +- `patch/engine/openpyxl_engine.py` → openpyxl 実行境界 +- `patch/engine/xlwings_engine.py` → xlwings(COM) 実行境界 +- `patch/ops/openpyxl_ops.py` → openpyxl 向け op 適用入口 +- `patch/ops/xlwings_ops.py` → xlwings 向け op 適用入口 +- `patch/normalize.py` / `patch/specs.py` → op 正規化と仕様メタデータ +- `shared/a1.py` / `shared/output_path.py` → A1 と出力 path の共通処理 + --- ## AI エージェント向けガイド diff --git a/docs/agents/DATA_MODEL.md b/docs/agents/DATA_MODEL.md index af411a8..51ddd7d 100644 --- a/docs/agents/DATA_MODEL.md +++ b/docs/agents/DATA_MODEL.md @@ -253,3 +253,26 @@ WorkbookData { - 0.14: `MergedCell` / `SheetData.merged_cells` を追加 - 0.15: `MergedCells` を schema + items 形式に変更し圧縮形式を導入 - 0.16: `SheetData.formulas_map` を追加 + +--- + +# Appendix A. MCP Patch Models + +MCP の patch/make で利用するモデル群は、後方互換のため +`exstruct.mcp.patch_runner` から引き続き import 可能です。 + +実体の配置は以下です。 + +- 実体モデル: `src/exstruct/mcp/patch/models.py` +- 互換ファサード: `src/exstruct/mcp/patch_runner.py` +- サービス層: `src/exstruct/mcp/patch/service.py` + +主なモデル: + +- `PatchOp` +- `PatchRequest` +- `MakeRequest` +- `PatchResult` +- `PatchDiffItem` +- `PatchErrorDetail` +- `FormulaIssue` diff --git a/docs/agents/FEATURE_SPEC.md b/docs/agents/FEATURE_SPEC.md index 3d210a2..84ccc47 100644 --- a/docs/agents/FEATURE_SPEC.md +++ b/docs/agents/FEATURE_SPEC.md @@ -1 +1,314 @@ -# Feature Spec for AI Agent (Phase-by-Phase) +# Feature Spec for AI Agent + +## Feature Name + +MCP Patch Architecture Refactor (Phase 1) + +## 背景 + +`src/exstruct/mcp/patch_runner.py` は 3,500 行超の単一モジュールとなっており、以下が混在している。 + +1. ドメインモデル定義(`PatchOp`, `PatchRequest`, `PatchResult`) +2. 入力検証(op 別バリデーション) +3. 実行制御(engine 選択、fallback、warning 集約) +4. backend 実装(openpyxl/xlwings) +5. 共通ユーティリティ(A1 変換、path 競合処理、色変換) + +この状態は、保守性・拡張性・テスト容易性を低下させるため、責務分離を行う。 + +## 目的 + +1. `patch_runner.py` を薄いファサードへ縮退する。 +2. patch 機能を「モデル」「正規化/検証」「実行制御」「backend 実装」に分離する。 +3. `server.py` と `patch_runner.py` に分散した patch op 正規化を共通化する。 +4. 重複ユーティリティ(A1、出力 path 競合処理)を共通化する。 +5. 公開 API 互換を維持しつつ、段階的に移行可能な構造にする。 + +## スコープ + +### In Scope + +1. `src/exstruct/mcp/patch/` 配下の新規モジュール導入 +2. `patch_runner.py` のファサード化 +3. patch op 正規化の共通化 +4. A1 変換・出力 path 解決の共通ユーティリティ化 +5. 既存テストの責務別再配置と追加 + +### Out of Scope + +1. 新しい patch op の追加 +2. MCP 外部 API の仕様変更 +3. 大規模なディレクトリ再編(`mcp` 全体の再設計) +4. `.xls` サポート方針の変更 + +## ターゲットアーキテクチャ + +```text +src/exstruct/mcp/ + patch/ + __init__.py + types.py + models.py + specs.py + normalize.py + validate.py + service.py + engine/ + base.py + openpyxl_engine.py + xlwings_engine.py + ops/ + common.py + openpyxl_ops.py + xlwings_ops.py + shared/ + a1.py + output_path.py +``` + +## モジュール責務 + +1. `patch/types.py` + 1. `PatchOpType`, `PatchBackend`, `PatchEngine`, `PatchStatus` 等の型定義 +2. `patch/models.py` + 1. `PatchOp`, `PatchRequest`, `MakeRequest`, `PatchResult` と snapshot 系モデル +3. `patch/specs.py` + 1. op ごとの required/optional/constraints/aliases を単一管理 +4. `patch/normalize.py` + 1. top-level `sheet` 適用 + 2. alias 正規化(`name`, `row`, `col`, `horizontal`, `vertical`, `color` など) +5. `patch/validate.py` + 1. `PatchOp` の整合性チェック(spec ベース) +6. `patch/service.py` + 1. `run_patch` / `run_make` のオーケストレーション + 2. engine 選択・fallback・warning/error 組み立て +7. `patch/engine/*` + 1. backend ごとの workbook 編集と保存責務 +8. `patch/ops/*` + 1. op 適用ロジック(backend 別) +9. `shared/a1.py` + 1. A1 解析、列変換、範囲展開の共通関数 +10. `shared/output_path.py` + 1. `on_conflict`、`rename`、出力先決定の共通関数 + +## 依存ルール + +1. `server.py` は patch 実装詳細に依存しない。 +2. `op_schema.py` は `patch_runner.py` ではなく `patch/specs.py` / `patch/types.py` に依存する。 +3. `tools.py` は `patch/service.py` と `patch/models.py` のみを利用する。 +4. backend 実装は `service.py` への逆依存を禁止する。 +5. 共通関数は `shared/*` に集約し、重複実装を禁止する。 + +## 互換性要件 + +1. 既存の公開 import は維持する(`exstruct.mcp.patch_runner` 経由の主要シンボル)。 +2. MCP tool I/F は変更しない(入力・出力 JSON 互換)。 +3. 既存 warning/error メッセージは可能な限り維持する。 +4. 既存テスト(`tests/mcp/test_patch_runner.py` ほか)を通す。 + +## 非機能要件 + +1. mypy strict: エラー 0 +2. Ruff (E, W, F, I, B, UP, N, C90): エラー 0 +3. 循環依存 0 +4. 1 モジュール 1 責務を優先し、巨大関数を分割する +5. 新規関数/クラスは Google スタイル docstring を付与する + +## 受け入れ条件(Acceptance Criteria) + +### AC-01 モジュール分離 + +1. `patch_runner.py` がファサード化され、実装詳細の大半が `patch/` 配下へ移動している。 + +### AC-02 正規化一元化 + +1. patch op alias 正規化ロジックが単一モジュールに集約され、`server.py` と `tools.py` から再利用される。 + +### AC-03 重複削減 + +1. A1 変換の重複実装が除去され、`shared/a1.py` に統一される。 +2. 出力 path 競合処理の重複実装が除去され、`shared/output_path.py` に統一される。 + +### AC-04 互換性維持 + +1. 既存の MCP ツール呼び出しが回帰なく動作する。 +2. 既存 patch/make 関連テストが通過する。 + +### AC-05 品質ゲート + +1. `uv run task precommit-run` が成功する。 + +## テスト方針 + +1. 既存テストの回帰確認 + 1. `tests/mcp/test_patch_runner.py` + 2. `tests/mcp/test_make_runner.py` + 3. `tests/mcp/test_server.py` + 4. `tests/mcp/test_tools_handlers.py` +2. 新規テスト追加 + 1. `tests/mcp/patch/test_normalize.py` + 2. `tests/mcp/patch/test_service.py` + 3. `tests/mcp/shared/test_a1.py` + 4. `tests/mcp/shared/test_output_path.py` + +## リスクと対策 + +1. リスク: 分割中に import 互換が崩れる + 1. 対策: `patch_runner.py` で再エクスポートを維持し、段階移行する +2. リスク: warning/error 文言差分でテストが壊れる + 1. 対策: 既存文言互換を維持し、必要時は差分を明示してテスト更新する +3. リスク: engine 分離時の挙動差 + 1. 対策: backend ごとの回帰テストを先に固定してから移行する + +--- + +## Feature Name + +MCP Coverage Recovery (Post-Refactor) + +## 背景 + +MCP 大規模リファクタリング後、全体カバレッジが 80% から 78.24% に低下した。 +`coverage.xml` の未実行行は 1,654 行で、うち `src/exstruct/mcp/*` が 1,176 行(71.1%)を占める。 + +主因: +1. `src/exstruct/mcp/patch/internal.py`(59.42%, 806 miss) +2. `src/exstruct/mcp/patch/models.py`(77.74%, 181 miss) +3. `src/exstruct/mcp/server.py`(70.17%, 71 miss) + +## 目的 + +1. 全体カバレッジを 80%以上へ回復し維持する。 +2. 低下要因モジュールをテストで直接改善する。 +3. `omit` 依存の見かけ上の回復は行わない。 + +## スコープ + +### In Scope + +1. `tests/mcp/patch/*` の拡張(models/internal/service中心) +2. `tests/mcp/test_server.py` の未カバー分岐追加 +3. `tests/mcp/test_sheet_reader.py` / `tests/mcp/test_chunk_reader.py` の境界ケース追加 +4. CI ゲート設定の強化(`--cov-fail-under=80` と patch coverage 80%) +5. 追記ドキュメント(本仕様・タスク・テスト要件) + +### Out of Scope + +1. patch op の新規機能追加 +2. 公開 API 仕様変更 +3. 大規模ディレクトリ再編 + +## 実装方針 + +1. `patch/models.py` の validator 分岐を `pytest.mark.parametrize` で網羅する。 +2. `patch/internal.py` の openpyxl/xlwings 分岐、エラー分岐、保存可否分岐を網羅する。 +3. `server.py` の alias 正規化・A1 パース・エラーメッセージ経路を網羅する。 +4. `sheet_reader.py` / `chunk_reader.py` の境界ケースを追加する。 +5. CI を「全体80%未満で失敗」「変更行80%未満で失敗」にする。 + +## 公開API/インターフェース変更 + +1. Python 公開 API の変更は行わない。 +2. CI インターフェースとして以下を追加・変更する。 +3. テスト実行コマンドに `--cov-fail-under=80` を追加する。 +4. Codecov `patch` ステータス目標を `80%` に設定する。 + +## 受け入れ基準(Acceptance Criteria) + +1. `uv run pytest -m "not com and not render" --cov=exstruct --cov-report=xml --cov-fail-under=80` が成功する。 +2. `coverage.xml` の全体 line-rate が 80%以上である。 +3. Codecov patch coverage の required status が 80%以上である。 +4. `patch/internal.py`, `patch/models.py`, `server.py` の line-rate が現状より改善している。 +5. `uv run task precommit-run` が成功する(mypy strict / Ruff 含む)。 + +## リスクと対策 + +1. リスク: `patch/internal.py` の分岐が多く工数が膨らむ。 + 対策: 失敗系を `parametrize` 化し、網羅効率を最大化する。 +2. リスク: CI ゲート強化で一時的に失敗が増える。 + 対策: 不足テストを同PRで同時投入する。 +3. リスク: 互換レイヤー除外に後退する。 + 対策: 恒久除外を禁止し、必要時は別承認で期限付き措置とする。 + +--- + +## Feature Name + +PR #65 Review Follow-up (Stabilization) + +## 背景 + +2026-02-24 時点で PR #65 には GitHub 上で未解決レビュー指摘が残っている。 +主要な論点は次の 3 系統。 + +1. 機能不具合: `backend="auto"` かつ `apply_table_style` で COM が選択されると失敗する。 +2. ドキュメント不整合: `legacy_runner` 参照や alias 許容/禁止の記述が実装と一致しない。 +3. 設計改善提案: BaseModel 境界、tuple/dict 返却、Protocol 整理、A1戻り値モデル化など。 + +## 目的 + +1. マージ阻害となる不具合・矛盾(P0)を優先解消する。 +2. 低リスクで回収できる品質改善(P1)を同PRで解消する。 +3. 変更波及の大きい設計変更(P2)は別Epicへ分離し、短期安定性を優先する。 + +## 対応方針(指摘分類) + +### P0: 同PRで必ず対応 + +1. `src/exstruct/mcp/patch/service.py` + 1. `apply_table_style` を含む場合、`backend="auto"` でも openpyxl 側へルーティングする。 +2. `docs/agents/LEGACY_DEPENDENCY_INVENTORY.md` + 1. 削除済み `legacy_runner.py` 前提の記述を現行構成へ修正する。 +3. `docs/mcp.md` + 1. Mistake catalog と alias 仕様の矛盾(`color`, `horizontal`, `vertical`)を解消する。 + +### P1: 同PRで実施する低リスク改善 + +1. `src/exstruct/mcp/server.py` + 1. 未使用・重複の正規化 helper 群を削除し、`patch.normalize` への一元化を明確化する。 +2. Docstring 指摘 + 1. 変更差分内で欠落している docstring を追加し、品質ゲートの指摘ノイズを減らす。 +3. `src/exstruct/mcp/server.py::_register_tools` + 1. 追加済み引数(`default_on_conflict`, `artifact_bridge_dir`)を docstring に反映する。 +4. `src/exstruct/mcp/patch/engine/openpyxl_engine.py` / `src/exstruct/mcp/patch/ops/openpyxl_ops.py` + 1. openpyxl engine の構造化戻り値を tuple から `BaseModel`(`OpenpyxlEngineResult`)へ統一する。 +5. `tests/mcp/patch/test_service.py` + 1. 新規追加テスト・helper の docstring を Google スタイルで補完する。 + +### P2: 別Epicへ分離(今回は設計判断のみ) + +1. `patch/__init__.py` 公開 API 方針 + 1. 正規化 helper を公開し続けるか、内部化するかを決定する。 +2. `shared/a1.py` 戻り値の BaseModel 化と呼び出し側移行。 +3. `patch/internal.py` の外部ライブラリ境界(`Any` 受け + 内部正規化)再設計。 + +## スコープ + +### In Scope + +1. P0 の不具合修正とドキュメント整合。 +2. P1 の低リスクなコード整理・docstring 修正。 +3. P2 の設計判断結果をタスク化し、別Epicへ登録。 + +### Out of Scope + +1. P2 の大規模設計変更の実装完了。 +2. 公開 API の破壊的変更。 +3. リリース範囲を拡大する新機能追加。 + +## 受け入れ基準(Acceptance Criteria) + +1. `backend="auto"` + `apply_table_style` が Windows/COM 環境でも失敗しない。 +2. `LEGACY_DEPENDENCY_INVENTORY.md` と `docs/mcp.md` の記述が現行実装と一致する。 +3. PR #65 の P0 指摘が GitHub 上で解消済みになる。 +4. P1 指摘は対応完了、または理由付きで deferred が明記される。 +5. `uv run task precommit-run` が成功する。 + +## リスクと対策 + +1. リスク: P1 範囲が膨張し、P0 対応が遅延する。 + 1. 対策: P0 完了前は P2 由来変更を着手しない。 +2. リスク: 設計変更を同PRに混在させ、回帰リスクが上がる。 + 1. 対策: P2 は issue 化して別PRへ分離する。 +3. リスク: docstring 修正が大量化してレビュー負荷が上がる。 + 1. 対策: 変更差分に限定して優先対応し、残件は別タスクへ切り出す。 diff --git a/docs/agents/LEGACY_DEPENDENCY_INVENTORY.md b/docs/agents/LEGACY_DEPENDENCY_INVENTORY.md new file mode 100644 index 0000000..4803a21 --- /dev/null +++ b/docs/agents/LEGACY_DEPENDENCY_INVENTORY.md @@ -0,0 +1,29 @@ +# Legacy Dependency Inventory (Phase 2) + +更新日: 2026-02-24 + +`src/exstruct/mcp/patch/legacy_runner.py` は Phase 2 完了時に削除済みです。 +このドキュメントは、旧依存の棚卸し結果と現行の置換先を記録します。 + +## 旧依存の置換先 + +- 旧対象: `src/exstruct/mcp/patch/legacy_runner.py`(削除済み) +- 現行の責務分割: + - `src/exstruct/mcp/patch/service.py`: patch/make のオーケストレーション + - `src/exstruct/mcp/patch/engine/openpyxl_engine.py`: openpyxl backend 実行境界 + - `src/exstruct/mcp/patch/engine/xlwings_engine.py`: COM(xlwings) backend 実行境界 + - `src/exstruct/mcp/patch/runtime.py`: runtime ユーティリティ(engine選択・path・policy) + - `src/exstruct/mcp/patch/ops/*`: backend 別 op 適用ロジック + +## 互換レイヤ + +- `src/exstruct/mcp/patch_runner.py` + - 公開 import 互換を維持する薄いファサード + - 実体実装は `patch/service.py` 側に委譲 + +## テスト観点 + +- `tests/mcp/test_patch_runner.py` + - `patch_runner` の互換入口(委譲動作)を検証 +- `tests/mcp/patch/test_service.py` + - backend 選択・fallback・警告メッセージを検証 diff --git a/docs/agents/MODEL_MIGRATION_NOTES.md b/docs/agents/MODEL_MIGRATION_NOTES.md new file mode 100644 index 0000000..82a6a27 --- /dev/null +++ b/docs/agents/MODEL_MIGRATION_NOTES.md @@ -0,0 +1,22 @@ +# Patch Model Migration Notes (Phase 2) + +`patch/models.py` の実体モデル化を進める際の依存メモです。 + +## 現状の結合点 + +- `legacy_runner.py` が `PatchOp` / `PatchRequest` / `PatchResult` などの実体定義を保持 +- `service.py` / `runtime.py` / `ops/*` は `legacy_runner` の private 関数を呼び出す +- そのため、`models.py` に同名の別 `BaseModel` を作ると、mypy と実行時検証の両方で型不整合が発生 + +## 段階移行の推奨手順 + +1. `legacy_runner.py` のモデル定義を `patch/models.py` に移し、`legacy_runner.py` では import のみを行う +2. `legacy_runner.py` 内のモデルバリデーション補助関数(`PatchOp` 関連)を `models.py` 側に移設 +3. `runtime.py` / `ops/*` / `service.py` の型注釈を `patch.models` へ統一 +4. `tests/mcp/test_patch_runner.py` の互換テストを維持したまま `legacy_runner.py` 依存テストを `tests/mcp/patch/*` へ移管 +5. 最後に `legacy_runner.py` を削除し、`patch_runner.py` を公開 API の薄い入口に固定 + +## 注意点 + +- `PatchDiffItem` / `PatchErrorDetail` / `FormulaIssue` を先行して別モデル化すると、`PatchResult` 構築時の検証で失敗しやすい +- まずは **定義元を一本化** してから呼び出し側を差し替えるのが安全 diff --git a/docs/agents/ROADMAP.md b/docs/agents/ROADMAP.md index 36630fe..8e090a7 100644 --- a/docs/agents/ROADMAP.md +++ b/docs/agents/ROADMAP.md @@ -50,10 +50,22 @@ ## v0.4.0 -- MCPサーバー機能追加 +- MCPサーバー機能追加(Read Only MVP) ## v0.5.0 +- MCPサーバー機能強化(Write) + +## v0.5.5 + +- MCPサーバーの機能強化(図形、グラフ、画像対応) + +## v0.6.0 + +- バックエンドに LibreOffice を追加(COM と選択可能に) + +## v0.8.0 + - Excel Form Controls 解析 ## v1.0.0 diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index f2b0652..6e6a1cb 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -1,3 +1,247 @@ # Task List -未完了 [ ], 完了 [x] +未完了: `[ ]` / 完了: `[x]` + +## Epic: MCP Patch Architecture Refactor (Phase 1) + +## 0. 事前準備と合意 + +- [x] `FEATURE_SPEC.md` と本タスクの整合性確認 +- [x] 既存公開 API(import 経路・MCP I/F)の互換条件を明文化 +- [x] 回帰対象テスト群の確定(patch/make/server/tools) + +完了条件: +- [x] 仕様・互換条件・テスト対象がレビューで承認されている + +## 1. 共通ユーティリティ抽出(低リスク先行) + +- [x] `src/exstruct/mcp/shared/a1.py` を追加 +- [x] A1/列変換関数を `patch_runner.py`・`server.py` から移設 +- [x] `src/exstruct/mcp/shared/output_path.py` を追加 +- [x] 出力 path 解決/競合処理を `patch_runner.py`・`extract_runner.py` から移設 +- [x] 既存呼び出し元を共通ユーティリティ利用へ置換 + +完了条件: +- [x] A1 と output path の重複実装が削除されている +- [x] 関連テストが回帰なしで通る + +## 2. patch ドメイン分離(型とモデル) + +- [x] `src/exstruct/mcp/patch/types.py` を追加 +- [x] `PatchOpType` ほか patch 共通型を移設 +- [x] `src/exstruct/mcp/patch/models.py` を追加 +- [x] `PatchOp` / `PatchRequest` / `MakeRequest` / `PatchResult` と snapshot モデルを移設 +- [x] `patch_runner.py` から新モジュールを再エクスポート + +完了条件: +- [x] モデルが `patch_runner.py` 以外からも直接利用可能 +- [x] `patch_runner.py` のモデル定義が削減されている + +## 3. 正規化と仕様メタデータの一元化 + +- [x] `src/exstruct/mcp/patch/specs.py` を追加 +- [x] op ごとの required/optional/constraints/aliases を集約 +- [x] `src/exstruct/mcp/patch/normalize.py` を追加 +- [x] top-level `sheet` 解決と alias 正規化を移設 +- [x] `server.py` の `_coerce_patch_ops` 系を共通ロジック利用へ置換 +- [x] `tools.py` の top-level `sheet` 解決を共通ロジック利用へ置換 +- [x] `op_schema.py` の `PatchOpType` 依存を `patch/specs.py` / `patch/types.py` へ変更 + +完了条件: +- [x] patch op 正規化実装が単一ソース化されている +- [x] `server.py` と `tools.py` の重複ロジックが削減されている + +## 4. サービス層と backend 分離 + +- [x] `src/exstruct/mcp/patch/service.py` を追加 +- [x] `run_patch` / `run_make` のオーケストレーションを移設 +- [x] `src/exstruct/mcp/patch/engine/base.py` を追加(engine protocol) +- [x] `openpyxl` 実装を `engine/openpyxl_engine.py` へ移設 +- [x] `xlwings` 実装を `engine/xlwings_engine.py` へ移設 +- [x] 必要に応じて op 実装を `patch/ops/*` へ分離 +- [x] `patch_runner.py` を薄いファサードへ縮退 + +完了条件: +- [x] `patch_runner.py` の主責務が公開互換維持のみになっている +- [x] engine 分岐/実装が `service.py` と `engine/*` に分離されている + +## 5. テスト再配置と追加 + +- [x] `tests/mcp/patch/test_normalize.py` を追加 +- [x] `tests/mcp/patch/test_service.py` を追加 +- [x] `tests/mcp/shared/test_a1.py` を追加 +- [x] `tests/mcp/shared/test_output_path.py` を追加 +- [x] 既存テストを責務別に分割(必要箇所のみ) +- [x] `tests/mcp/test_patch_runner.py` の互換観点テストを維持 + +完了条件: +- [x] 新規分割モジュールに直接対応するテストが存在する +- [x] 既存互換テストが通る + +## 6. ドキュメント更新 + +- [x] `docs/agents/ARCHITECTURE.md` に新構成を反映 +- [x] `docs/agents/DATA_MODEL.md` の patch モデル参照先を更新 +- [x] 必要に応じて `docs/mcp.md` の内部実装説明を更新 + +完了条件: +- [x] 参照先のコードパスが現行構成と一致している + +## 7. 品質ゲート + +- [x] `uv run task precommit-run` 実行 +- [x] 失敗時は修正して再実行 +- [x] 変更差分の自己レビュー(責務分離・循環依存・互換性) + +完了条件: +- [x] mypy strict: 0 エラー +- [x] Ruff: 0 エラー +- [x] テスト: 全て成功 + +## 8. レガシー実装完全廃止(Phase 2) + +- [x] `src/exstruct/mcp/patch/legacy_runner.py` 依存の棚卸し(import/呼び出し元を全列挙) +- [x] `patch/service.py` / `patch/engine/*` の `legacy_runner` 依存を新モジュール群へ置換 +- [x] `patch/models.py` の `patch_runner` 経由 import を廃止し、実体モデル定義へ移行 +- [x] `patch_runner.py` の monkeypatch 互換レイヤを段階的に削除(必要な公開 API は維持) +- [x] `tests/mcp/test_patch_runner.py` の私有関数前提テストを責務別テストへ移管 +- [x] `src/exstruct/mcp/patch/ops/*` を導入し、op 実装を backend 別に分離 +- [x] `legacy_runner.py` を削除し、不要な再エクスポートを整理 +- [x] 互換性要件を満たしたまま `uv run task precommit-run` と回帰テストを再通過 + +完了条件: +- [x] `legacy_runner.py` がリポジトリから削除されている +- [x] `patch_runner.py` が公開 API の薄い入口のみを保持している +- [x] patch 実装の依存方向が `service -> engine/ops` に一本化されている +- [x] 既存 MCP I/F 互換とテスト成功が維持されている + +## 優先順位 + +1. P0: 1, 2, 3 +2. P1: 4, 5 +3. P2: 6, 7, 8 + +## マイルストーン(推奨) + +1. M1: 共通ユーティリティ抽出完了(Task 1) +2. M2: ドメイン/正規化分離完了(Task 2-3) +3. M3: service/engine 分離完了(Task 4) +4. M4: テスト・ドキュメント・品質ゲート完了(Task 5-7) +5. M5: レガシー実装完全廃止完了(Task 8) + +--- + +## Epic: MCP Coverage Recovery (Post-Refactor) + +## 0. 現状固定と差分計測 + +- [x] `coverage.xml` を基準値として保存(78.24% / miss 1,654) +- [x] 低下主因3ファイルの未実行行を記録(internal/models/server) +- [ ] 改善後比較用コマンドを固定化 +- [ ] 完了条件: before/after の比較表が作成されている + +## 1. `patch/models.py` 分岐網羅 + +- [x] `PatchOp` 各 validator の失敗系を `parametrize` で追加 +- [x] alias競合・必須不足・型不正・範囲不正のケースを追加 +- [x] `set_style` / `set_alignment` / `set_dimensions` の境界値ケースを追加 +- [ ] 完了条件: `models.py` の未実行行を大幅削減(目安 80+ 行カバー) + +## 2. `patch/internal.py` 分岐網羅 + +- [ ] openpyxl 適用系(成功/失敗/skip)を fixtureベースで追加 +- [ ] `dry_run` / `preflight_formula_check` / `return_inverse_ops` の分岐を追加 +- [x] unsupported op / sheet not found / range shape mismatch を網羅 +- [ ] conflict policy(overwrite/rename/skip)の分岐を網羅 +- [ ] 完了条件: `internal.py` の未実行行を大幅削減(目安 250+ 行カバー) + +## 3. `server.py` 未カバー経路の補完 + +- [x] alias正規化 helper のエラー経路テストを追加 +- [x] draw_grid_border range shorthand の不正入力テストを追加 +- [x] patch op JSON parse の例外文言テストを追加 +- [ ] 完了条件: `server.py` の line-rate を有意改善(目安 +10pt 以上) + +## 4. Reader系の境界ケース補完 + +- [x] `test_sheet_reader.py` に invalid range / empty result / boundary を追加 +- [x] `test_chunk_reader.py` に cursor/filter/max_bytes 境界テストを追加 +- [ ] 完了条件: `sheet_reader.py` と `chunk_reader.py` の未実行行を削減 + +## 5. CIゲート強化 + +- [x] テストコマンドに `--cov-fail-under=80` を追加 +- [x] `codecov.yml` の patch target を `80%` に設定 +- [ ] PR時に project/patch 両ステータスを required として運用 +- [ ] 完了条件: 80% 未満のPRがCIで確実に失敗する + +## 6. 検証 + +- [x] `uv run task precommit-run` 実行 +- [ ] `uv run pytest -m "not com and not render" --cov=exstruct --cov-report=xml --cov-fail-under=80` 実行 +- [x] `uv run coverage report -m` で改善確認 +- [ ] 完了条件: 全体80%以上、主要低下ファイルの改善、静的解析0エラー + +--- + +## Epic: PR #65 Review Follow-up (Stabilization) + +## 0. レビュー棚卸し(GitHub MCP) + +- [x] PR #65 の unresolved review threads を一覧化(2026-02-24基準) +- [x] 指摘を `P0(即対応)/P1(同PR対応)/P2(別Epic)` に分類 +- [x] 分類結果を `FEATURE_SPEC.md` と同期 + +完了条件: +- [x] 指摘一覧・優先度・対応方針が文書化されている + +## 1. P0: 機能不具合修正 + +- [x] `src/exstruct/mcp/patch/service.py` で `apply_table_style` を含む `backend="auto"` 要求を openpyxl へルーティング +- [x] Windows/COM 有効時の回帰テストを追加(`tests/mcp/patch/test_service.py`) +- [x] 既存 `backend="com"` fallback との挙動差分がないことを確認 + +完了条件: +- [x] `apply_table_style` が `backend="auto"` で失敗しない +- [x] 追加テストが安定して通る + +## 2. P0: ドキュメント整合修正 + +- [x] `docs/agents/LEGACY_DEPENDENCY_INVENTORY.md` の `legacy_runner` 前提記述を現行実装へ更新 +- [x] `docs/mcp.md` Mistake catalog の alias 記述矛盾(`color` / `horizontal` / `vertical`)を解消 +- [x] ドキュメント内の参照パス整合を再確認 + +完了条件: +- [x] 指摘対象ドキュメントが実装仕様と一致している + +## 3. P1: 低リスク品質改善(同PR) + +- [x] `src/exstruct/mcp/server.py` の重複・未使用正規化 helper 群を削除し `patch.normalize` に一本化 +- [x] `_register_tools` docstring に `default_on_conflict` / `artifact_bridge_dir` を追記 +- [x] 変更差分内の docstring 欠落を補完(テスト含む) +- [x] `src/exstruct/mcp/patch/engine/openpyxl_engine.py` / `patch/ops/openpyxl_ops.py` の戻り値を `OpenpyxlEngineResult`(BaseModel)へ統一 +- [x] `src/exstruct/mcp/patch/service.py` の openpyxl 呼び出しを結果モデル参照へ更新 +- [x] `tests/mcp/patch/test_service.py` の新規 helper/テストに Google スタイル docstring を追加 + +完了条件: +- [x] 重複ロジック削減後も既存テストが回帰しない +- [x] docstring 指摘の主要残件が解消される +- [x] openpyxl engine 境界の tuple 返却が解消される + +## 4. P2: 別Epicへ分離する設計課題 + +- [ ] `patch/__init__.py` 公開API(正規化 helper 公開の是非)を設計判断 +- [ ] `shared/a1.py` 戻り値モデル化と呼び出し側移行を別PRタスク化 +- [ ] `patch/internal.py` の外部境界型(`Any` + 正規化)再設計を別PRタスク化 + +完了条件: +- [ ] 各課題に owner / 期限 / 受け入れ基準が付与された別タスクが起票済み + +## 5. 検証とクローズ + +- [x] `uv run task precommit-run` 実行 +- [x] 必要テスト(patch/service/server/docs 関連)を実行 +- [x] GitHub 上で P0 指摘を resolve、P1/P2 は方針コメントを残す + +完了条件: +- [x] P0 が全て完了し、PR #65 のマージ阻害が解消されている diff --git a/docs/mcp.md b/docs/mcp.md index d28ec87..267f722 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,7 @@ so AI agents can call it safely as a tool. ## What it provides - Convert Excel into structured JSON (file output) +- Create a new workbook and apply initial ops in one call - Edit Excel by applying patch operations (cell/sheet updates) - Read large JSON outputs in chunks - Read A1 ranges / specific cells / formulas directly from extracted JSON @@ -54,17 +55,22 @@ exstruct-mcp --root C:\\data --log-file C:\\logs\\exstruct-mcp.log --on-conflict - `--log-level`: `DEBUG` / `INFO` / `WARNING` / `ERROR` - `--log-file`: Log file path (stderr is still used by default) - `--on-conflict`: Output conflict policy (`overwrite` / `skip` / `rename`) +- `--artifact-bridge-dir`: Directory used by `mirror_artifact=true` to copy output files - `--warmup`: Preload heavy imports to reduce first-call latency ## Tools - `exstruct_extract` +- `exstruct_make` - `exstruct_patch` +- `exstruct_list_ops` +- `exstruct_describe_op` - `exstruct_read_json_chunk` - `exstruct_read_range` - `exstruct_read_cells` - `exstruct_read_formulas` - `exstruct_validate_input` +- `exstruct_get_runtime_info` ### `exstruct_extract` defaults and mode guide @@ -98,6 +104,15 @@ Example sequence: { "tool": "exstruct_read_json_chunk", "out_path": "C:\\data\\book.json", "sheet": "Sheet1", "max_bytes": 50000 } ``` +If path behavior is unclear, inspect runtime info first: + +```json +{ "tool": "exstruct_get_runtime_info" } +``` + +When a path is outside `--root`, the error message also recommends +`exstruct_get_runtime_info` with a relative path example. + ## Basic flow 1. Call `exstruct_extract` to generate the output JSON file @@ -173,6 +188,63 @@ Examples: 2. If response has `next_cursor`, call again with that cursor 3. Repeat until `next_cursor` is `null` +## Edit flow (make/patch) + +### New workbook flow (`exstruct_make`) + +1. Build patch operations (`ops`) for initial sheets/cells +2. Call `exstruct_make` with `out_path` +3. Re-run `exstruct_extract` to verify results if needed + +### `exstruct_make` highlights + +- Creates a new workbook and applies `ops` in one call +- `out_path` is required +- `ops` is optional (empty list is allowed) +- Supported output extensions: `.xlsx`, `.xlsm`, `.xls` +- Initial sheet behavior: + - default is `Sheet1` + - when `sheet` is specified and the same name is not created by `add_sheet`, + the initial sheet is created with that `sheet` name + - when `add_sheet` creates the same name, initial sheet remains `Sheet1` +- Reuses patch pipeline, so `patch_diff`/`error` shape is compatible with `exstruct_patch` +- Supports the same extended flags as `exstruct_patch`: + - `dry_run` + - `return_inverse_ops` + - `preflight_formula_check` + - `auto_formula` + - `backend` + - `sheet` (top-level default sheet for non-`add_sheet` ops) +- `.xls` constraints: + - requires Windows Excel COM + - rejects `backend="openpyxl"` + - rejects `dry_run`/`return_inverse_ops`/`preflight_formula_check` + +Example: + +```json +{ + "tool": "exstruct_make", + "out_path": "C:\\data\\new_book.xlsx", + "ops": [ + { "op": "add_sheet", "sheet": "Data" }, + { "op": "set_value", "sheet": "Data", "cell": "A1", "value": "hello" } + ] +} +``` + +### Internal implementation note + +The patch implementation is layered to keep compatibility while enabling refactoring: + +- `exstruct.mcp.patch_runner`: compatibility facade (existing import path) +- `exstruct.mcp.patch.service`: patch/make orchestration +- `exstruct.mcp.patch.engine.*`: backend execution boundaries (openpyxl/com) +- `exstruct.mcp.patch.runtime`: runtime utilities (path/backend selection) +- `exstruct.mcp.patch.ops.*`: backend-specific op application entrypoints + +This keeps MCP tool I/O stable while allowing internal module separation. + ## Edit flow (patch) 1. Inspect workbook structure with `exstruct_extract` (and `exstruct_read_json_chunk` if needed) @@ -193,12 +265,255 @@ Examples: - `fill_formula` - `set_value_if` - `set_formula_if` + - `draw_grid_border` + - `set_bold` + - `set_font_size` + - `set_font_color` + - `set_fill_color` + - `set_dimensions` + - `auto_fit_columns` + - `merge_cells` + - `unmerge_cells` + - `set_alignment` + - `set_style` + - `apply_table_style` + - `restore_design_snapshot` (internal inverse op) - Useful flags: - `dry_run`: compute diff only (no file write) - `return_inverse_ops`: return undo operations - `preflight_formula_check`: detect formula issues before save - `auto_formula`: treat `=...` in `set_value` as formula + - `sheet`: top-level default sheet used when `op.sheet` is omitted (non-`add_sheet` only) + - `mirror_artifact`: copy output workbook to `--artifact-bridge-dir` on success +- Large ops guidance: + - `ops` over `200` still runs, but returns a warning that recommends splitting into batches. +- Backend selection: + - `backend="auto"` (default): prefers COM when available; otherwise openpyxl. + Also uses openpyxl when `dry_run`/`return_inverse_ops`/`preflight_formula_check` is enabled. + Requests including `apply_table_style` are also routed to openpyxl. + - `backend="com"`: forces COM. Requires Excel COM and rejects + `dry_run`/`return_inverse_ops`/`preflight_formula_check`. + If `apply_table_style` is included, returns a warning and falls back to openpyxl. + - `backend="openpyxl"`: forces openpyxl (`.xls` is not supported). +- Output includes `engine` (`"com"` or `"openpyxl"`) to show which backend was actually used. +- Output includes `mirrored_out_path` when mirroring is requested and succeeds. - Conflict handling follows server `--on-conflict` unless overridden per tool call +- `restore_design_snapshot` remains openpyxl-only. +- Sheet resolution order: + - `op.sheet` is used when present + - otherwise top-level `sheet` is used for non-`add_sheet` ops + - `add_sheet` still requires explicit `op.sheet` (or alias `name`) + +### `set_style` quick guide + +- Purpose: apply multiple style fields in one op. +- Target: exactly one of `cell` or `range`. +- Need at least one style field: `bold`, `font_size`, `color`, `fill_color`, + `horizontal_align`, `vertical_align`, `wrap_text`. + +Example: + +```json +{ + "tool": "exstruct_patch", + "xlsx_path": "C:\\data\\book.xlsx", + "ops": [ + { + "op": "set_style", + "sheet": "Sheet1", + "range": "A1:D1", + "bold": true, + "color": "#FFFFFF", + "fill_color": "#1F3864", + "horizontal_align": "center", + "vertical_align": "center", + "wrap_text": true + } + ] +} +``` + +### `apply_table_style` quick guide + +- Purpose: create a table and apply an Excel table style in one op. +- Required: `sheet`, `range`, `style`. +- Optional: `table_name`. +- Fails when range intersects an existing table, or table name duplicates. + +Example: + +```json +{ + "tool": "exstruct_patch", + "xlsx_path": "C:\\data\\book.xlsx", + "ops": [ + { + "op": "apply_table_style", + "sheet": "Sheet1", + "range": "A1:D11", + "style": "TableStyleMedium9", + "table_name": "SalesTable" + } + ] +} +``` + +### `auto_fit_columns` quick guide + +- Purpose: auto-fit column widths and optionally clamp with min/max bounds. +- Required: `sheet`. +- Optional: `columns`, `min_width`, `max_width`. +- `columns` supports Excel letters and numeric indexes (for example `["A", 2]`). +- If `columns` is omitted, used columns are targeted. + +Example: + +```json +{ + "tool": "exstruct_patch", + "xlsx_path": "C:\\data\\book.xlsx", + "ops": [ + { + "op": "auto_fit_columns", + "sheet": "Sheet1", + "columns": ["A", 2], + "min_width": 8, + "max_width": 40 + } + ] +} +``` + +### Color fields (`color` / `fill_color`) + +- `set_font_color` uses `color` (font color only) +- `set_fill_color` uses `fill_color` (background fill only) +- Accepted formats for both fields: + - `RRGGBB` + - `AARRGGBB` + - `#RRGGBB` + - `#AARRGGBB` +- Values are normalized internally to uppercase with leading `#`. + +Examples: + +```json +{ + "tool": "exstruct_patch", + "xlsx_path": "C:\\data\\book.xlsx", + "ops": [ + { "op": "set_font_color", "sheet": "Sheet1", "cell": "A1", "color": "1f4e79" }, + { "op": "set_fill_color", "sheet": "Sheet1", "range": "A1:C1", "fill_color": "CC336699" } + ] +} +``` + +### Alias and shorthand inputs + +- `add_sheet`: `name` is accepted as an alias of `sheet` +- `set_dimensions`: + - `row` -> `rows` + - `col` -> `columns` + - `height` -> `row_height` + - `width` -> `column_width` + - `columns` accepts both letters (`"A"`, `"AA"`) and positive indexes (`1`, `2`, ...) +- `draw_grid_border`: `range` shorthand is accepted and normalized to + `base_cell` + `row_count` + `col_count` +- `set_alignment`: + - `horizontal` -> `horizontal_align` + - `vertical` -> `vertical_align` +- `set_fill_color`: + - `color` -> `fill_color` +- Relative `out_path` for `exstruct_make` is resolved from MCP `--root`. + +### Mirror artifact handoff + +- `exstruct_patch` / `exstruct_make` input: + - `mirror_artifact` (default: `false`) +- Output: + - `mirrored_out_path` (`null` when not mirrored) +- Behavior: + - Mirroring runs only on success. + - If `--artifact-bridge-dir` is not set, process still succeeds and warning is returned. + - If copy fails, process still succeeds and warning is returned. + +## Op schema discovery tools + +- `exstruct_list_ops` + - Returns available op names and short descriptions. +- `exstruct_describe_op` + - Input: `op` + - Output: `required`, `optional`, `constraints`, `example`, `aliases` + +Examples: + +```json +{ "tool": "exstruct_list_ops" } +``` + +```json +{ "tool": "exstruct_describe_op", "op": "set_fill_color" } +``` + +## Mistake catalog (error -> fix) + +- Wrong (conflicting alias and canonical field): + - `{"op":"set_fill_color","sheet":"Sheet1","cell":"A1","color":"#D9E1F2","fill_color":"#FFFFFF"}` +- Correct: + - `{"op":"set_fill_color","sheet":"Sheet1","cell":"A1","fill_color":"#D9E1F2"}` + +- Wrong (conflicting alias and canonical field): + - `{"op":"set_alignment","sheet":"Sheet1","cell":"A1","horizontal":"center","horizontal_align":"left"}` +- Correct: + - `{"op":"set_alignment","sheet":"Sheet1","cell":"A1","horizontal_align":"center","vertical_align":"center"}` + +- Note: + - `color` (`set_fill_color`) and `horizontal`/`vertical` (`set_alignment`) are accepted aliases. + - Canonical fields (`fill_color`, `horizontal_align`, `vertical_align`) are recommended. + +### In-place overwrite recipe + +To overwrite the original workbook path explicitly: + +1. set `out_name` to the same filename as the input workbook +2. set `on_conflict` to `overwrite` +3. keep `out_dir` empty (or set it to the same directory) + +Example: + +```json +{ + "tool": "exstruct_patch", + "xlsx_path": "C:\\data\\book.xlsx", + "out_name": "book.xlsx", + "on_conflict": "overwrite", + "ops": [ + { "op": "set_value", "sheet": "Sheet1", "cell": "A1", "value": "updated" } + ] +} +``` + +### Runtime info tool + +- `exstruct_get_runtime_info` returns: + - `root` + - `cwd` + - `platform` + - `path_examples` (`relative` and `absolute`) + +Example response (shape): + +```json +{ + "root": "C:\\data", + "cwd": "C:\\Users\\agent\\workspace", + "platform": "win32", + "path_examples": { + "relative": "outputs/book.xlsx", + "absolute": "C:\\data\\outputs\\book.xlsx" + } +} +``` ## AI agent configuration examples diff --git a/docs/release-notes/v0.5.0.md b/docs/release-notes/v0.5.0.md new file mode 100644 index 0000000..305fa1b --- /dev/null +++ b/docs/release-notes/v0.5.0.md @@ -0,0 +1,35 @@ +# v0.5.0 Release Notes + +This release expands MCP editing from MVP scope to practical workbook design +flows, and adds new MCP tools for workbook creation, op discovery, and runtime +diagnostics. + +## Highlights + +- Added `exstruct_make` for one-call workbook creation and initial `ops` apply. + - `out_path` is required, `ops` is optional. + - Supports `.xlsx`, `.xlsm`, and `.xls` (with COM constraints on `.xls`). +- Expanded `exstruct_patch` design editing operations: + - `draw_grid_border`, `set_bold`, `set_font_size`, `set_font_color`, + `set_fill_color`, `set_dimensions`, `auto_fit_columns`, `merge_cells`, + `unmerge_cells`, `set_alignment`, `set_style`, `apply_table_style` + - Internal inverse op: `restore_design_snapshot` +- Added MCP operation schema discovery tools: + - `exstruct_list_ops` + - `exstruct_describe_op` +- Added MCP runtime diagnostics tool: + - `exstruct_get_runtime_info` +- Improved patch UX and output controls: + - Top-level `sheet` fallback for non-`add_sheet` ops (`op.sheet` has higher + priority when both are present) + - Artifact mirroring via `mirror_artifact` and server `--artifact-bridge-dir` + - Backend controls for patch/make: `backend` input and `engine` output + +## Notes + +- `backend="auto"` now prefers COM when available, with controlled fallback to + openpyxl for compatible cases. +- `apply_table_style` requested with `backend="com"` falls back to openpyxl + with a warning. +- MCP docs and README pages were updated to reflect the new make/patch flows + and operation schema guidance. diff --git a/mkdocs.yml b/mkdocs.yml index feb386f..67d50d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,8 @@ nav: - MCP Server: mcp.md - Concept / Why ExStruct?: concept.md - Release Notes: + - v0.5.0: release-notes/v0.5.0.md + - v0.4.4: release-notes/v0.4.4.md - v0.4.0: release-notes/v0.4.0.md - v0.3.7: release-notes/v0.3.7.md - v0.3.6: release-notes/v0.3.6.md diff --git a/pyproject.toml b/pyproject.toml index d09640e..1b98e32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "exstruct" -version = "0.4.4" +version = "0.5.0" description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines" readme = "README.md" license = { file = "LICENSE" } @@ -68,6 +68,7 @@ omit = [ "tests/*", "*/test_*.py", "*/gen_py/*", + "src/exstruct/mcp/patch/engine/base.py", ] [tool.ruff] @@ -136,9 +137,9 @@ ruff = "ruff check ." ruff-fix = "ruff check . --fix" mypy = "mypy src/exstruct --strict" precommit-run = "pre-commit run -a" -test = "pytest -vv --cov=exstruct --cov-report=term-missing --cov-report=xml" # uv sync --extra render --extra toon -test-unit = "pytest -vv -m \"not com and not render\" --cov=exstruct --cov-report=term-missing --cov-report=xml" -test-com = "pytest -vv -m \"com\" --cov=exstruct --cov-report=term-missing --cov-report=xml" +test = "pytest -vv --cov=exstruct --cov-report=term-missing --cov-report=xml --cov-fail-under=80" # uv sync --extra render --extra toon +test-unit = "pytest -vv -m \"not com and not render\" --cov=exstruct --cov-report=term-missing --cov-report=xml --cov-fail-under=80" +test-com = "pytest -vv -m \"com\" --cov=exstruct --cov-report=term-missing --cov-report=xml --cov-fail-under=80" codecov-unit = "codecov-cli upload-process -f coverage.xml -F unit -C %CODECOV_SHA% -t %CODECOV_TOKEN%" codecov-com = "codecov-cli upload-process -f coverage.xml -F com -C %CODECOV_SHA% -t %CODECOV_TOKEN%" docs = "mkdocs serve" diff --git a/schemas/sheet.json b/schemas/sheet.json index 4a1eb5d..8d40dee 100644 --- a/schemas/sheet.json +++ b/schemas/sheet.json @@ -751,6 +751,14 @@ "default": null, "description": "Merged cell ranges on the sheet." }, + "merged_ranges": { + "description": "Merged ranges in A1 notation (e.g., 'A1:C3'). Used in alpha_col-oriented output.", + "items": { + "type": "string" + }, + "title": "Merged Ranges", + "type": "array" + }, "print_areas": { "description": "User-defined print areas.", "items": { diff --git a/schemas/workbook.json b/schemas/workbook.json index eb99d41..7936b46 100644 --- a/schemas/workbook.json +++ b/schemas/workbook.json @@ -627,6 +627,14 @@ "default": null, "description": "Merged cell ranges on the sheet." }, + "merged_ranges": { + "description": "Merged ranges in A1 notation (e.g., 'A1:C3'). Used in alpha_col-oriented output.", + "items": { + "type": "string" + }, + "title": "Merged Ranges", + "type": "array" + }, "print_areas": { "description": "User-defined print areas.", "items": { diff --git a/src/exstruct/mcp/__init__.py b/src/exstruct/mcp/__init__.py index 4be3bc5..e32eb99 100644 --- a/src/exstruct/mcp/__init__.py +++ b/src/exstruct/mcp/__init__.py @@ -18,12 +18,14 @@ from .io import PathPolicy from .patch_runner import ( FormulaIssue, + MakeRequest, PatchDiffItem, PatchErrorDetail, PatchOp, PatchRequest, PatchResult, PatchValue, + run_make, run_patch, ) from .sheet_reader import ( @@ -40,8 +42,13 @@ read_range, ) from .tools import ( + DescribeOpToolInput, + DescribeOpToolOutput, ExtractToolInput, ExtractToolOutput, + ListOpsToolOutput, + MakeToolInput, + MakeToolOutput, PatchToolInput, PatchToolOutput, ReadCellsToolInput, @@ -54,7 +61,10 @@ ReadRangeToolOutput, ValidateInputToolInput, ValidateInputToolOutput, + run_describe_op_tool, run_extract_tool, + run_list_ops_tool, + run_make_tool, run_patch_tool, run_read_cells_tool, run_read_formulas_tool, @@ -69,6 +79,8 @@ ) __all__ = [ + "DescribeOpToolInput", + "DescribeOpToolOutput", "ExtractRequest", "ExtractResult", "ExtractOptions", @@ -76,6 +88,10 @@ "ExtractToolOutput", "FormulaIssue", "FormulaReadItem", + "ListOpsToolOutput", + "MakeRequest", + "MakeToolInput", + "MakeToolOutput", "PatchDiffItem", "PatchErrorDetail", "PatchOp", @@ -111,7 +127,11 @@ "read_json_chunk", "validate_input", "run_extract", + "run_describe_op_tool", "run_extract_tool", + "run_list_ops_tool", + "run_make", + "run_make_tool", "run_patch", "run_patch_tool", "read_cells", diff --git a/src/exstruct/mcp/extract_runner.py b/src/exstruct/mcp/extract_runner.py index 387bc00..e832966 100644 --- a/src/exstruct/mcp/extract_runner.py +++ b/src/exstruct/mcp/extract_runner.py @@ -9,6 +9,11 @@ from exstruct import ExtractionMode, process_excel from .io import PathPolicy +from .shared.output_path import ( + apply_conflict_policy as _shared_apply_conflict_policy, + next_available_path as _shared_next_available_path, + resolve_output_path as _shared_resolve_output_path, +) logger = logging.getLogger(__name__) @@ -179,14 +184,14 @@ def _resolve_output_path( Raises: ValueError: If the path violates the policy. """ - target_dir = out_dir or input_path.parent - target_dir = policy.ensure_allowed(target_dir) if policy else target_dir.resolve() - suffix = _format_suffix(fmt) - name = _normalize_output_name(input_path, out_name, suffix) - output_path = (target_dir / name).resolve() - if policy is not None: - output_path = policy.ensure_allowed(output_path) - return output_path + return _shared_resolve_output_path( + input_path, + out_dir=out_dir, + out_name=out_name, + policy=policy, + default_suffix=_format_suffix(fmt), + default_name_builder="same_stem", + ) def _normalize_output_name(input_path: Path, out_name: str | None, suffix: str) -> str: @@ -259,35 +264,12 @@ def _apply_conflict_policy( Returns: Tuple of (resolved output path, warning message or None, skipped flag). """ - if not output_path.exists(): - return output_path, None, False - if on_conflict == "skip": - return ( - output_path, - f"Output exists; skipping write: {output_path.name}", - True, - ) - if on_conflict == "rename": - renamed = _next_available_path(output_path) - return ( - renamed, - f"Output exists; renamed to: {renamed.name}", - False, - ) - return output_path, None, False + return _shared_apply_conflict_policy(output_path, on_conflict) def _next_available_path(path: Path) -> Path: """Return the next available path by appending a numeric suffix.""" - if not path.exists(): - return path - stem = path.stem - suffix = path.suffix - for idx in range(1, 10_000): - candidate = path.with_name(f"{stem}_{idx}{suffix}") - if not candidate.exists(): - return candidate - raise RuntimeError(f"Failed to resolve unique path for {path}") + return _shared_next_available_path(path) def _try_read_workbook_meta(path: Path) -> tuple[WorkbookMeta | None, list[str]]: diff --git a/src/exstruct/mcp/io.py b/src/exstruct/mcp/io.py index 45b0d38..b863041 100644 --- a/src/exstruct/mcp/io.py +++ b/src/exstruct/mcp/io.py @@ -33,14 +33,24 @@ def ensure_allowed(self, path: Path) -> Path: Raises: ValueError: If the path is outside the root or denied by glob. """ - resolved = path.resolve() root = self.normalize_root() + resolved = self._resolve_from_root(path, root) if resolved != root and root not in resolved.parents: - raise ValueError(f"Path is outside root: {resolved}") + raise ValueError( + "Path is outside root. " + f"resolved={resolved}, root={root}, " + "example_relative='outputs/book.xlsx'. " + "Use exstruct_get_runtime_info to inspect valid root-based paths." + ) if self._is_denied(resolved, root): raise ValueError(f"Path is denied by policy: {resolved}") return resolved + def _resolve_from_root(self, path: Path, root: Path) -> Path: + """Resolve path using root as the base for relative inputs.""" + candidate = path if path.is_absolute() else root / path + return candidate.resolve() + def _is_denied(self, path: Path, root: Path) -> bool: """Check if a path is denied by glob rules. diff --git a/src/exstruct/mcp/op_schema.py b/src/exstruct/mcp/op_schema.py new file mode 100644 index 0000000..9479a6c --- /dev/null +++ b/src/exstruct/mcp/op_schema.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +from typing import Any, get_args + +from pydantic import BaseModel, Field + +from .patch.types import PatchOpType + + +class PatchOpSchema(BaseModel): + """Mini schema metadata for a patch operation.""" + + op: PatchOpType + description: str + required: list[str] = Field(default_factory=list) + optional: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + example: dict[str, Any] + aliases: dict[str, str] = Field(default_factory=dict) + + +def list_patch_op_schemas() -> list[PatchOpSchema]: + """Return patch operation schemas in canonical op order.""" + ordered_ops = list(get_args(PatchOpType)) + return [_PATCH_OP_SCHEMA_BY_NAME[op] for op in ordered_ops] + + +def get_patch_op_schema(op: str) -> PatchOpSchema | None: + """Return schema for one patch op name.""" + schema = _PATCH_OP_SCHEMA_BY_NAME.get(op) + return schema + + +def build_patch_tool_mini_schema() -> str: + """Build a human-readable mini schema section for exstruct_patch doc.""" + lines: list[str] = [] + lines.append("Mini op schema (required/optional/constraints/example/aliases):") + lines.append( + "Sheet resolution: non-add_sheet ops allow top-level sheet fallback; " + "op.sheet overrides top-level sheet." + ) + for schema in list_patch_op_schemas(): + display_schema = schema_with_sheet_resolution_rules(schema) + lines.append(f"- {schema.op}: {schema.description}") + lines.append( + " required: " + + ( + ", ".join(display_schema.required) + if display_schema.required + else "(none)" + ) + ) + lines.append( + " optional: " + + ( + ", ".join(display_schema.optional) + if display_schema.optional + else "(none)" + ) + ) + lines.append( + " constraints: " + + ( + ", ".join(display_schema.constraints) + if display_schema.constraints + else "(none)" + ) + ) + lines.append(f" example: {display_schema.example}") + lines.append( + " aliases: " + + ( + ", ".join( + f"{alias} -> {canonical}" + for alias, canonical in sorted(display_schema.aliases.items()) + ) + if display_schema.aliases + else "(none)" + ) + ) + return "\n".join(lines) + + +def schema_with_sheet_resolution_rules(schema: PatchOpSchema) -> PatchOpSchema: + """Return display schema with top-level sheet resolution notes.""" + if "sheet" not in schema.required: + return schema + updated_required = [ + ( + "sheet (or top-level sheet)" + if item == "sheet" and schema.op != "add_sheet" + else item + ) + for item in schema.required + ] + updated_constraints = list(schema.constraints) + if schema.op == "add_sheet": + updated_constraints.append("top-level sheet is not used for add_sheet") + else: + updated_constraints.append( + "op.sheet overrides top-level sheet when both are set" + ) + return schema.model_copy( + update={"required": updated_required, "constraints": updated_constraints} + ) + + +_PATCH_OP_SCHEMA_BY_NAME: dict[str, PatchOpSchema] = { + "set_value": PatchOpSchema( + op="set_value", + description="Set a scalar value to one cell.", + required=["sheet", "cell", "value"], + optional=[], + constraints=[ + "cell target only", + "use auto_formula=true to allow values starting with '='", + ], + example={"op": "set_value", "sheet": "Sheet1", "cell": "A1", "value": "Hello"}, + ), + "set_formula": PatchOpSchema( + op="set_formula", + description="Set one formula string to one cell.", + required=["sheet", "cell", "formula"], + optional=[], + constraints=["formula must start with '='"], + example={ + "op": "set_formula", + "sheet": "Sheet1", + "cell": "B2", + "formula": "=SUM(B1:B10)", + }, + ), + "add_sheet": PatchOpSchema( + op="add_sheet", + description="Add a new worksheet by name.", + required=["sheet"], + optional=[], + constraints=["sheet name must be unique in workbook"], + example={"op": "add_sheet", "sheet": "Data"}, + aliases={"name": "sheet"}, + ), + "set_range_values": PatchOpSchema( + op="set_range_values", + description="Set a 2D values matrix to a rectangular range.", + required=["sheet", "range", "values"], + optional=[], + constraints=["values shape must match range rows x cols"], + example={ + "op": "set_range_values", + "sheet": "Sheet1", + "range": "A1:B2", + "values": [[1, 2], [3, 4]], + }, + ), + "fill_formula": PatchOpSchema( + op="fill_formula", + description="Fill a base formula across target range.", + required=["sheet", "range", "base_cell", "formula"], + optional=[], + constraints=["formula must start with '='"], + example={ + "op": "fill_formula", + "sheet": "Sheet1", + "range": "C2:C10", + "base_cell": "C2", + "formula": "=A2+B2", + }, + ), + "set_value_if": PatchOpSchema( + op="set_value_if", + description="Set value when current value matches expected.", + required=["sheet", "cell", "expected", "value"], + optional=[], + constraints=["no-op when expected mismatch"], + example={ + "op": "set_value_if", + "sheet": "Sheet1", + "cell": "A1", + "expected": "old", + "value": "new", + }, + ), + "set_formula_if": PatchOpSchema( + op="set_formula_if", + description="Set formula when current value matches expected.", + required=["sheet", "cell", "expected", "formula"], + optional=[], + constraints=["formula must start with '='", "no-op when expected mismatch"], + example={ + "op": "set_formula_if", + "sheet": "Sheet1", + "cell": "C5", + "expected": 0, + "formula": "=A5+B5", + }, + ), + "draw_grid_border": PatchOpSchema( + op="draw_grid_border", + description="Draw thin black borders for a rectangular region.", + required=["sheet", "base_cell", "row_count", "col_count"], + optional=[], + constraints=["row_count > 0", "col_count > 0", "or use range shorthand alias"], + example={ + "op": "draw_grid_border", + "sheet": "Sheet1", + "base_cell": "A1", + "row_count": 5, + "col_count": 4, + }, + aliases={"range": "base_cell + row_count + col_count"}, + ), + "set_bold": PatchOpSchema( + op="set_bold", + description="Apply bold font to one cell or one range.", + required=["sheet"], + optional=["cell", "range", "bold"], + constraints=["exactly one of cell or range"], + example={"op": "set_bold", "sheet": "Sheet1", "range": "A1:D1", "bold": True}, + ), + "set_font_size": PatchOpSchema( + op="set_font_size", + description="Apply font size to one cell or one range.", + required=["sheet", "font_size"], + optional=["cell", "range"], + constraints=["exactly one of cell or range", "font_size > 0"], + example={ + "op": "set_font_size", + "sheet": "Sheet1", + "cell": "A1", + "font_size": 14, + }, + ), + "set_font_color": PatchOpSchema( + op="set_font_color", + description="Apply font color to one cell or one range.", + required=["sheet", "color"], + optional=["cell", "range"], + constraints=[ + "exactly one of cell or range", + "hex color (#RRGGBB or #AARRGGBB)", + ], + example={ + "op": "set_font_color", + "sheet": "Sheet1", + "range": "A1:D1", + "color": "#1F4E79", + }, + ), + "set_fill_color": PatchOpSchema( + op="set_fill_color", + description="Apply fill color to one cell or one range.", + required=["sheet", "fill_color"], + optional=["cell", "range"], + constraints=[ + "exactly one of cell or range", + "hex color (#RRGGBB or #AARRGGBB)", + ], + example={ + "op": "set_fill_color", + "sheet": "Sheet1", + "range": "A1:D1", + "fill_color": "#D9E1F2", + }, + aliases={"color": "fill_color"}, + ), + "set_dimensions": PatchOpSchema( + op="set_dimensions", + description="Set row height and/or column width.", + required=["sheet"], + optional=["rows", "columns", "row_height", "column_width"], + constraints=[ + "at least one of row_height or column_width", + "row_height > 0, column_width > 0", + ], + example={ + "op": "set_dimensions", + "sheet": "Sheet1", + "rows": [1, 2], + "row_height": 22, + "columns": ["A", "B"], + "column_width": 18, + }, + aliases={ + "row": "rows", + "col": "columns", + "height": "row_height", + "width": "column_width", + }, + ), + "auto_fit_columns": PatchOpSchema( + op="auto_fit_columns", + description="Auto-fit column widths with optional width bounds.", + required=["sheet"], + optional=["columns", "min_width", "max_width"], + constraints=[ + "columns optional (uses used columns when omitted)", + "min_width > 0, max_width > 0 when provided", + "min_width <= max_width when both are provided", + ], + example={ + "op": "auto_fit_columns", + "sheet": "Sheet1", + "columns": ["A", 2], + "min_width": 8, + "max_width": 40, + }, + ), + "merge_cells": PatchOpSchema( + op="merge_cells", + description="Merge one rectangular range.", + required=["sheet", "range"], + optional=[], + constraints=["range must be rectangular"], + example={"op": "merge_cells", "sheet": "Sheet1", "range": "A1:C1"}, + ), + "unmerge_cells": PatchOpSchema( + op="unmerge_cells", + description="Unmerge merged cells intersecting range.", + required=["sheet", "range"], + optional=[], + constraints=["range must be rectangular"], + example={"op": "unmerge_cells", "sheet": "Sheet1", "range": "A1:C1"}, + ), + "set_alignment": PatchOpSchema( + op="set_alignment", + description="Set alignment flags to one cell or one range.", + required=["sheet"], + optional=["cell", "range", "horizontal_align", "vertical_align", "wrap_text"], + constraints=[ + "exactly one of cell or range", + "specify at least one alignment field", + ], + example={ + "op": "set_alignment", + "sheet": "Sheet1", + "range": "A1:D1", + "horizontal_align": "center", + "vertical_align": "center", + "wrap_text": True, + }, + aliases={"horizontal": "horizontal_align", "vertical": "vertical_align"}, + ), + "set_style": PatchOpSchema( + op="set_style", + description="Apply multiple style attributes in one op.", + required=["sheet"], + optional=[ + "cell", + "range", + "bold", + "font_size", + "color", + "fill_color", + "horizontal_align", + "vertical_align", + "wrap_text", + ], + constraints=[ + "exactly one of cell or range", + "specify at least one style field", + "font_size > 0", + "target cell count <= style limit", + ], + example={ + "op": "set_style", + "sheet": "Sheet1", + "range": "A1:D1", + "bold": True, + "color": "#FFFFFF", + "fill_color": "#1F3864", + "horizontal_align": "center", + }, + ), + "apply_table_style": PatchOpSchema( + op="apply_table_style", + description="Create table and apply Excel table style.", + required=["sheet", "range", "style"], + optional=["table_name"], + constraints=[ + "range must include header row", + "table name must be unique when provided", + "range must not intersect existing table", + "backend='com' falls back to openpyxl with warning", + ], + example={ + "op": "apply_table_style", + "sheet": "Sheet1", + "range": "A1:D11", + "style": "TableStyleMedium9", + "table_name": "SalesTable", + }, + ), + "restore_design_snapshot": PatchOpSchema( + op="restore_design_snapshot", + description="Internal inverse op to restore style snapshot.", + required=["sheet", "design_snapshot"], + optional=[], + constraints=["internal use for inverse operations"], + example={ + "op": "restore_design_snapshot", + "sheet": "Sheet1", + "design_snapshot": {"range": "A1:A1", "cells": []}, + }, + ), +} diff --git a/src/exstruct/mcp/patch/__init__.py b/src/exstruct/mcp/patch/__init__.py new file mode 100644 index 0000000..b13a45b --- /dev/null +++ b/src/exstruct/mcp/patch/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .normalize import coerce_patch_ops, resolve_top_level_sheet_for_payload +from .specs import PATCH_OP_SPECS, PatchOpSpec +from .types import PatchOpType + +__all__ = [ + "PATCH_OP_SPECS", + "PatchOpType", + "PatchOpSpec", + "coerce_patch_ops", + "resolve_top_level_sheet_for_payload", +] diff --git a/src/exstruct/mcp/patch/engine/__init__.py b/src/exstruct/mcp/patch/engine/__init__.py new file mode 100644 index 0000000..3dc0fac --- /dev/null +++ b/src/exstruct/mcp/patch/engine/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .openpyxl_engine import apply_openpyxl_engine +from .xlwings_engine import apply_xlwings_engine + +__all__ = ["apply_openpyxl_engine", "apply_xlwings_engine"] diff --git a/src/exstruct/mcp/patch/engine/base.py b/src/exstruct/mcp/patch/engine/base.py new file mode 100644 index 0000000..adc858d --- /dev/null +++ b/src/exstruct/mcp/patch/engine/base.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from exstruct.mcp.patch.types import PatchOpType + + +class OpenpyxlPatchEngine(Protocol): + """Protocol for openpyxl patch engine adapters.""" + + def apply( + self, + request: object, + input_path: Path, + output_path: Path, + ) -> tuple[list[object], list[object], list[object], list[str]]: + """Apply patch operations via openpyxl-compatible engine.""" + + +class XlwingsPatchEngine(Protocol): + """Protocol for xlwings patch engine adapters.""" + + def apply( + self, + input_path: Path, + output_path: Path, + ops: list[object], + auto_formula: bool, + ) -> list[object]: + """Apply patch operations via xlwings-compatible engine.""" + + +__all__ = ["OpenpyxlPatchEngine", "PatchOpType", "XlwingsPatchEngine"] diff --git a/src/exstruct/mcp/patch/engine/openpyxl_engine.py b/src/exstruct/mcp/patch/engine/openpyxl_engine.py new file mode 100644 index 0000000..53221d4 --- /dev/null +++ b/src/exstruct/mcp/patch/engine/openpyxl_engine.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + +from exstruct.mcp.patch.models import OpenpyxlEngineResult, PatchRequest +from exstruct.mcp.patch.ops.openpyxl_ops import apply_openpyxl_ops + + +def apply_openpyxl_engine( + request: PatchRequest, + input_path: Path, + output_path: Path, +) -> OpenpyxlEngineResult: + """Apply patch operations using the existing openpyxl backend implementation.""" + return apply_openpyxl_ops(request, input_path, output_path) + + +__all__ = ["apply_openpyxl_engine"] diff --git a/src/exstruct/mcp/patch/engine/xlwings_engine.py b/src/exstruct/mcp/patch/engine/xlwings_engine.py new file mode 100644 index 0000000..0e35d2d --- /dev/null +++ b/src/exstruct/mcp/patch/engine/xlwings_engine.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + +from exstruct.mcp.patch.models import PatchOp +from exstruct.mcp.patch.ops.xlwings_ops import apply_xlwings_ops + + +def apply_xlwings_engine( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, +) -> list[object]: + """Apply patch operations using the existing xlwings backend implementation.""" + return apply_xlwings_ops(input_path, output_path, ops, auto_formula) + + +__all__ = ["apply_xlwings_engine"] diff --git a/src/exstruct/mcp/patch/internal.py b/src/exstruct/mcp/patch/internal.py new file mode 100644 index 0000000..06ebe59 --- /dev/null +++ b/src/exstruct/mcp/patch/internal.py @@ -0,0 +1,3930 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from copy import copy +from pathlib import Path +import re +from typing import Any, Protocol, cast, runtime_checkable +from uuid import uuid4 + +from pydantic import BaseModel, Field, field_validator, model_validator +import xlwings as xw + +from exstruct.cli.availability import get_com_availability as get_com_availability + +from ..extract_runner import OnConflictPolicy +from ..io import PathPolicy +from ..shared.a1 import ( + column_index_to_label as _shared_column_index_to_label, + column_label_to_index as _shared_column_label_to_index, + range_cell_count as _shared_range_cell_count, + split_a1 as _shared_split_a1, +) +from ..shared.output_path import ( + apply_conflict_policy as _shared_apply_conflict_policy, + next_available_path as _shared_next_available_path, + resolve_output_path as _shared_resolve_output_path, +) +from .types import ( + FormulaIssueCode, + FormulaIssueLevel, + HorizontalAlignType, + PatchBackend, + PatchEngine, + PatchOpType, + PatchStatus, + PatchValueKind, + VerticalAlignType, +) + +_ALLOWED_EXTENSIONS = {".xlsx", ".xlsm", ".xls"} +_A1_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*$") +_A1_RANGE_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*:[A-Za-z]{1,3}[1-9][0-9]*$") +_HEX_COLOR_PATTERN = re.compile(r"^#?(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") +_COLUMN_LABEL_PATTERN = re.compile(r"^[A-Za-z]{1,3}$") +_MAX_STYLE_TARGET_CELLS = 10_000 +_SOFT_MAX_OPS_WARNING_THRESHOLD = 200 + +_XLWINGS_HORIZONTAL_ALIGN_MAP: dict[HorizontalAlignType, int] = { + "general": -4105, + "left": -4131, + "center": -4108, + "right": -4152, + "fill": 5, + "justify": -4130, + "centerContinuous": 7, + "distributed": -4117, +} +_XLWINGS_VERTICAL_ALIGN_MAP: dict[VerticalAlignType, int] = { + "top": -4160, + "center": -4108, + "bottom": -4107, + "justify": -4130, + "distributed": -4117, +} + + +class BorderSideSnapshot(BaseModel): + """Serializable border side state for inverse restoration.""" + + style: str | None = None + color: str | None = None + + +class BorderSnapshot(BaseModel): + """Serializable border state for one cell.""" + + cell: str + top: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + right: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + bottom: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + left: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + + +class FontSnapshot(BaseModel): + """Serializable font state for one cell.""" + + cell: str + bold: bool | None = None + size: float | None = None + color: str | None = None + + +class FillSnapshot(BaseModel): + """Serializable fill state for one cell.""" + + cell: str + fill_type: str | None = None + start_color: str | None = None + end_color: str | None = None + + +class AlignmentSnapshot(BaseModel): + """Serializable alignment state for one cell.""" + + cell: str + horizontal: str | None = None + vertical: str | None = None + wrap_text: bool | None = None + + +class MergeStateSnapshot(BaseModel): + """Serializable merged-range state for deterministic restoration.""" + + scope: str + ranges: list[str] = Field(default_factory=list) + + +class RowDimensionSnapshot(BaseModel): + """Serializable row height state.""" + + row: int + height: float | None = None + + +class ColumnDimensionSnapshot(BaseModel): + """Serializable column width state.""" + + column: str + width: float | None = None + + +class DesignSnapshot(BaseModel): + """Serializable style/dimension snapshot for inverse restore.""" + + borders: list[BorderSnapshot] = Field(default_factory=list) + fonts: list[FontSnapshot] = Field(default_factory=list) + fills: list[FillSnapshot] = Field(default_factory=list) + alignments: list[AlignmentSnapshot] = Field(default_factory=list) + merge_state: MergeStateSnapshot | None = None + row_dimensions: list[RowDimensionSnapshot] = Field(default_factory=list) + column_dimensions: list[ColumnDimensionSnapshot] = Field(default_factory=list) + + +@runtime_checkable +class OpenpyxlCellProtocol(Protocol): + """Protocol for openpyxl cell access used by patch runner.""" + + value: str | int | float | None + data_type: str | None + font: OpenpyxlFontProtocol + fill: OpenpyxlFillProtocol + border: OpenpyxlBorderProtocol + alignment: OpenpyxlAlignmentProtocol + + +@runtime_checkable +class OpenpyxlColorProtocol(Protocol): + """Protocol for openpyxl color access.""" + + rgb: object | None + + +@runtime_checkable +class OpenpyxlSideProtocol(Protocol): + """Protocol for openpyxl border side access.""" + + style: str | None + color: OpenpyxlColorProtocol | None + + +@runtime_checkable +class OpenpyxlBorderProtocol(Protocol): + """Protocol for openpyxl border access.""" + + top: OpenpyxlSideProtocol + right: OpenpyxlSideProtocol + bottom: OpenpyxlSideProtocol + left: OpenpyxlSideProtocol + + +@runtime_checkable +class OpenpyxlFontProtocol(Protocol): + """Protocol for openpyxl font access.""" + + bold: bool | None + size: float | None + color: object | None + + +@runtime_checkable +class OpenpyxlFillProtocol(Protocol): + """Protocol for openpyxl fill access.""" + + fill_type: str | None + start_color: OpenpyxlColorProtocol | None + end_color: OpenpyxlColorProtocol | None + + +@runtime_checkable +class OpenpyxlAlignmentProtocol(Protocol): + """Protocol for openpyxl alignment access.""" + + horizontal: str | None + vertical: str | None + wrap_text: bool | None + + +@runtime_checkable +class OpenpyxlRowDimensionProtocol(Protocol): + """Protocol for openpyxl row dimension access.""" + + height: float | None + + +@runtime_checkable +class OpenpyxlColumnDimensionProtocol(Protocol): + """Protocol for openpyxl column dimension access.""" + + width: float | None + + +@runtime_checkable +class OpenpyxlRowDimensionsProtocol(Protocol): + """Protocol for openpyxl row dimensions collection.""" + + def __getitem__(self, key: int) -> OpenpyxlRowDimensionProtocol: ... + + +@runtime_checkable +class OpenpyxlColumnDimensionsProtocol(Protocol): + """Protocol for openpyxl column dimensions collection.""" + + def __getitem__(self, key: str) -> OpenpyxlColumnDimensionProtocol: ... + + +@runtime_checkable +class OpenpyxlWorksheetProtocol(Protocol): + """Protocol for openpyxl worksheet access used by patch runner.""" + + row_dimensions: OpenpyxlRowDimensionsProtocol + column_dimensions: OpenpyxlColumnDimensionsProtocol + + def __getitem__(self, key: str) -> OpenpyxlCellProtocol: ... + + def merge_cells(self, range_string: str) -> None: ... + + def unmerge_cells(self, range_string: str) -> None: ... + + +@runtime_checkable +class OpenpyxlTablesProtocol(Protocol): + """Protocol for openpyxl worksheet tables collection.""" + + def items(self) -> Iterator[tuple[object, object]]: ... + + +@runtime_checkable +class OpenpyxlWorkbookProtocol(Protocol): + """Protocol for openpyxl workbook access used by patch runner.""" + + sheetnames: list[str] + + def __getitem__(self, key: str) -> OpenpyxlWorksheetProtocol: ... + + def create_sheet(self, title: str) -> OpenpyxlWorksheetProtocol: ... + + def save(self, filename: str | Path) -> None: ... + + def close(self) -> None: ... + + +@runtime_checkable +class XlwingsRangeProtocol(Protocol): + """Protocol for xlwings range access used by patch runner.""" + + value: object | None + formula: str | None + api: object + + +@runtime_checkable +class XlwingsSheetProtocol(Protocol): + """Protocol for xlwings sheet access used by patch runner.""" + + name: str + api: object + + def range(self, cell: str) -> XlwingsRangeProtocol: ... + + +@runtime_checkable +class XlwingsSheetsProtocol(Protocol): + """Protocol for xlwings sheets collection.""" + + def __iter__(self) -> Iterator[XlwingsSheetProtocol]: ... + + def __len__(self) -> int: ... + + def __getitem__(self, index: int) -> XlwingsSheetProtocol: ... + + def add( + self, name: str, after: XlwingsSheetProtocol | None = None + ) -> XlwingsSheetProtocol: ... + + +@runtime_checkable +class XlwingsWorkbookProtocol(Protocol): + """Protocol for xlwings workbook access used by patch runner.""" + + sheets: XlwingsSheetsProtocol + + def save(self, filename: str) -> None: ... + + def close(self) -> None: ... + + +@runtime_checkable +class XlwingsAppProtocol(Protocol): + """Protocol for xlwings app lifecycle used during cleanup.""" + + display_alerts: bool + screen_updating: bool + + def quit(self) -> None: ... # noqa: N802 + + def kill(self) -> None: ... # noqa: N802 + + +@runtime_checkable +class XlwingsFontApiProtocol(Protocol): + """Protocol for xlwings COM font API.""" + + Bold: bool + Size: float + Color: int + + +@runtime_checkable +class XlwingsInteriorApiProtocol(Protocol): + """Protocol for xlwings COM interior API.""" + + Color: int + + +@runtime_checkable +class XlwingsBorderApiProtocol(Protocol): + """Protocol for xlwings COM border API.""" + + LineStyle: int + Color: int + + +@runtime_checkable +class XlwingsMergeAreaApiProtocol(Protocol): + """Protocol for xlwings COM merged-area API.""" + + def Address(self, row_absolute: bool, column_absolute: bool) -> str: ... # noqa: N802 + + +@runtime_checkable +class XlwingsRangeApiProtocol(Protocol): + """Protocol for xlwings COM range API.""" + + Font: XlwingsFontApiProtocol + Interior: XlwingsInteriorApiProtocol + MergeCells: bool + MergeArea: XlwingsMergeAreaApiProtocol + HorizontalAlignment: int + VerticalAlignment: int + WrapText: bool + + def Borders(self, edge: int) -> XlwingsBorderApiProtocol: ... # noqa: N802 + + def Merge(self) -> None: ... # noqa: N802 + + def UnMerge(self) -> None: ... # noqa: N802 + + +@runtime_checkable +class XlwingsRowApiProtocol(Protocol): + """Protocol for xlwings COM row API.""" + + RowHeight: float + + +@runtime_checkable +class XlwingsColumnApiProtocol(Protocol): + """Protocol for xlwings COM column API.""" + + ColumnWidth: float + + def AutoFit(self) -> None: ... # noqa: N802 + + +@runtime_checkable +class XlwingsSheetApiProtocol(Protocol): + """Protocol for xlwings COM sheet API.""" + + def Rows(self, index: int) -> XlwingsRowApiProtocol: ... # noqa: N802 + + def Columns(self, key: str) -> XlwingsColumnApiProtocol: ... # noqa: N802 + + +class PatchOp(BaseModel): + """Single patch operation for an Excel workbook. + + Operation types and their required fields: + + - ``set_value``: Set a cell value. Requires ``sheet``, ``cell``, ``value``. + - ``set_formula``: Set a cell formula. Requires ``sheet``, ``cell``, ``formula`` (must start with ``=``). + - ``add_sheet``: Add a new worksheet. Requires ``sheet`` (new sheet name). No ``cell``/``value``/``formula``. + - ``set_range_values``: Set values for a rectangular range. Requires ``sheet``, ``range`` (e.g. ``A1:C3``), ``values`` (2D list matching range shape). + - ``fill_formula``: Fill a formula across a single row or column. Requires ``sheet``, ``range``, ``base_cell``, ``formula``. + - ``set_value_if``: Conditionally set value. Requires ``sheet``, ``cell``, ``value``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. + - ``set_formula_if``: Conditionally set formula. Requires ``sheet``, ``cell``, ``formula``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. + - ``draw_grid_border``: Draw thin black borders on a target rectangle. + - ``set_bold``: Set bold style for one cell or one range. + - ``set_font_size``: Set font size for one cell or one range. + - ``set_font_color``: Set font color for one cell or one range. + - ``set_fill_color``: Set solid fill color for one cell or one range. + - ``set_dimensions``: Set row height and/or column width. + - ``auto_fit_columns``: Auto-fit column widths with optional bounds. + - ``merge_cells``: Merge a rectangular range. + - ``unmerge_cells``: Unmerge all merged ranges intersecting target range. + - ``set_alignment``: Set horizontal/vertical alignment and/or wrap_text. + - ``set_style``: Set multiple style attributes in one operation. + - ``apply_table_style``: Create an Excel table and apply table style. + - ``restore_design_snapshot``: Restore style/dimension snapshot (internal inverse op). + """ + + op: PatchOpType = Field( + description=( + "Operation type: 'set_value', 'set_formula', 'add_sheet', " + "'set_range_values', 'fill_formula', 'set_value_if', 'set_formula_if', " + "'draw_grid_border', 'set_bold', 'set_font_size', 'set_font_color', " + "'set_fill_color', " + "'set_dimensions', " + "'auto_fit_columns', " + "'merge_cells', 'unmerge_cells', 'set_alignment', 'set_style', " + "'apply_table_style', " + "or 'restore_design_snapshot'." + ) + ) + sheet: str = Field( + description="Target sheet name. For add_sheet, this is the new sheet name." + ) + cell: str | None = Field( + default=None, + description="Cell reference in A1 notation (e.g. 'B2'). Required for set_value, set_formula, set_value_if, set_formula_if.", + ) + range: str | None = Field( + default=None, + description="Range reference in A1 notation (e.g. 'A1:C3'). Required for set_range_values and fill_formula.", + ) + base_cell: str | None = Field( + default=None, + description="Base cell for formula translation in fill_formula (e.g. 'C2').", + ) + expected: str | int | float | None = Field( + default=None, + description="Expected current value for conditional ops (set_value_if, set_formula_if). Operation is skipped if mismatch.", + ) + value: str | int | float | None = Field( + default=None, + description="Value to set. Use null to clear a cell. For set_value and set_value_if.", + ) + values: list[list[str | int | float | None]] | None = Field( + default=None, + description="2D list of values for set_range_values. Shape must match the range dimensions.", + ) + formula: str | None = Field( + default=None, + description="Formula string starting with '=' (e.g. '=SUM(A1:A10)'). For set_formula, set_formula_if, fill_formula.", + ) + row_count: int | None = Field( + default=None, + description="Row count for draw_grid_border.", + ) + col_count: int | None = Field( + default=None, + description="Column count for draw_grid_border.", + ) + bold: bool | None = Field( + default=None, + description="Bold flag for set_bold. Defaults to true.", + ) + font_size: float | None = Field( + default=None, + description="Font size for set_font_size. Must be > 0.", + ) + color: str | None = Field( + default=None, + description="Font color for set_font_color in RRGGBB/AARRGGBB (with optional '#').", + ) + fill_color: str | None = Field( + default=None, + description="Fill color for set_fill_color in RRGGBB/AARRGGBB (with optional '#').", + ) + rows: list[int] | None = Field( + default=None, + description="Row indexes for set_dimensions.", + ) + columns: list[str | int] | None = Field( + default=None, + description="Column identifiers for set_dimensions. Accepts letters (A/AA) or positive indexes.", + ) + row_height: float | None = Field( + default=None, + description="Target row height for set_dimensions.", + ) + column_width: float | None = Field( + default=None, + description="Target column width for set_dimensions.", + ) + min_width: float | None = Field( + default=None, + description="Optional minimum width bound for auto_fit_columns.", + ) + max_width: float | None = Field( + default=None, + description="Optional maximum width bound for auto_fit_columns.", + ) + horizontal_align: HorizontalAlignType | None = Field( + default=None, + description="Horizontal alignment for set_alignment/set_style.", + ) + vertical_align: VerticalAlignType | None = Field( + default=None, + description="Vertical alignment for set_alignment/set_style.", + ) + wrap_text: bool | None = Field( + default=None, + description="Wrap text flag for set_alignment/set_style.", + ) + style: str | None = Field( + default=None, + description="Table style name for apply_table_style.", + ) + table_name: str | None = Field( + default=None, + description="Optional table name for apply_table_style.", + ) + design_snapshot: DesignSnapshot | None = Field( + default=None, + description="Design snapshot payload for restore_design_snapshot.", + ) + + @field_validator("sheet") + @classmethod + def _validate_sheet(cls, value: str) -> str: + if not value.strip(): + raise ValueError("sheet must not be empty.") + return value + + @field_validator("cell") + @classmethod + def _validate_cell(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_PATTERN.match(candidate): + raise ValueError(f"Invalid cell reference: {value}") + return candidate.upper() + + @field_validator("base_cell") + @classmethod + def _validate_base_cell(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_PATTERN.match(candidate): + raise ValueError(f"Invalid base_cell reference: {value}") + return candidate.upper() + + @field_validator("range") + @classmethod + def _validate_range(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_RANGE_PATTERN.match(candidate): + raise ValueError(f"Invalid range reference: {value}") + start, end = candidate.split(":", maxsplit=1) + return f"{start.upper()}:{end.upper()}" + + @field_validator("fill_color") + @classmethod + def _validate_fill_color(cls, value: str | None) -> str | None: + if value is None: + return None + return _normalize_hex_input(value, field_name="fill_color") + + @field_validator("color") + @classmethod + def _validate_color(cls, value: str | None) -> str | None: + if value is None: + return None + return _normalize_hex_input(value, field_name="color") + + @field_validator("rows") + @classmethod + def _validate_rows(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return None + if not value: + raise ValueError("rows must not be empty.") + normalized: list[int] = [] + for row in value: + if row < 1: + raise ValueError("rows must contain positive integers.") + normalized.append(row) + return normalized + + @field_validator("columns") + @classmethod + def _validate_columns(cls, value: list[str | int] | None) -> list[str | int] | None: + if value is None: + return None + if not value: + raise ValueError("columns must not be empty.") + normalized: list[str | int] = [] + for column in value: + normalized.append(_normalize_column_identifier(column)) + return normalized + + @field_validator("style", "table_name") + @classmethod + def _validate_non_empty_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not candidate: + raise ValueError("style/table_name must not be empty when provided.") + return candidate + + @field_validator("min_width", "max_width") + @classmethod + def _validate_optional_positive_width(cls, value: float | None) -> float | None: + if value is None: + return None + if value <= 0: + raise ValueError("min_width/max_width must be > 0.") + return value + + @model_validator(mode="after") + def _validate_op(self) -> PatchOp: + validator = _validator_for_op(self.op) + if validator is None: + return self + if self.op in _CELL_REQUIRED_OPS: + _validate_cell_required(self) + validator(self) + return self + + +_CELL_REQUIRED_OPS: set[PatchOpType] = { + "set_value", + "set_formula", + "set_value_if", + "set_formula_if", +} + + +def _validator_for_op(op_type: PatchOpType) -> Callable[[PatchOp], None] | None: + """Return per-op validator function.""" + validators: dict[PatchOpType, Callable[[PatchOp], None]] = { + "add_sheet": _validate_add_sheet, + "set_value": _validate_set_value, + "set_formula": _validate_set_formula, + "set_range_values": _validate_set_range_values, + "fill_formula": _validate_fill_formula, + "set_value_if": _validate_set_value_if, + "set_formula_if": _validate_set_formula_if, + "draw_grid_border": _validate_draw_grid_border, + "set_bold": _validate_set_bold, + "set_font_size": _validate_set_font_size, + "set_font_color": _validate_set_font_color, + "set_fill_color": _validate_set_fill_color, + "set_dimensions": _validate_set_dimensions, + "auto_fit_columns": _validate_auto_fit_columns, + "merge_cells": _validate_merge_cells, + "unmerge_cells": _validate_unmerge_cells, + "set_alignment": _validate_set_alignment, + "set_style": _validate_set_style, + "apply_table_style": _validate_apply_table_style, + "restore_design_snapshot": _validate_restore_design_snapshot, + } + return validators.get(op_type) + + +def _validate_add_sheet(op: PatchOp) -> None: + """Validate add_sheet operation.""" + _validate_no_design_fields(op, op_name="add_sheet") + if op.cell is not None: + raise ValueError("add_sheet does not accept cell.") + if op.range is not None: + raise ValueError("add_sheet does not accept range.") + if op.base_cell is not None: + raise ValueError("add_sheet does not accept base_cell.") + if op.expected is not None: + raise ValueError("add_sheet does not accept expected.") + if op.value is not None: + raise ValueError("add_sheet does not accept value.") + if op.values is not None: + raise ValueError("add_sheet does not accept values.") + if op.formula is not None: + raise ValueError("add_sheet does not accept formula.") + + +def _validate_cell_required(op: PatchOp) -> None: + """Validate that the operation has a cell value.""" + if op.cell is None: + raise ValueError(f"{op.op} requires cell.") + + +def _validate_set_value(op: PatchOp) -> None: + """Validate set_value operation.""" + _validate_no_design_fields(op, op_name="set_value") + if op.range is not None: + raise ValueError("set_value does not accept range.") + if op.base_cell is not None: + raise ValueError("set_value does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_value does not accept expected.") + if op.values is not None: + raise ValueError("set_value does not accept values.") + if op.formula is not None: + raise ValueError("set_value does not accept formula.") + + +def _validate_set_formula(op: PatchOp) -> None: + """Validate set_formula operation.""" + _validate_no_design_fields(op, op_name="set_formula") + if op.range is not None: + raise ValueError("set_formula does not accept range.") + if op.base_cell is not None: + raise ValueError("set_formula does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_formula does not accept expected.") + if op.values is not None: + raise ValueError("set_formula does not accept values.") + if op.value is not None: + raise ValueError("set_formula does not accept value.") + if op.formula is None: + raise ValueError("set_formula requires formula.") + if not op.formula.startswith("="): + raise ValueError("set_formula requires formula starting with '='.") + + +def _validate_set_range_values(op: PatchOp) -> None: + """Validate set_range_values operation.""" + _validate_no_design_fields(op, op_name="set_range_values") + if op.cell is not None: + raise ValueError("set_range_values does not accept cell.") + if op.base_cell is not None: + raise ValueError("set_range_values does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_range_values does not accept expected.") + if op.formula is not None: + raise ValueError("set_range_values does not accept formula.") + if op.range is None: + raise ValueError("set_range_values requires range.") + if op.values is None: + raise ValueError("set_range_values requires values.") + if not op.values: + raise ValueError("set_range_values requires non-empty values.") + if not all(op.values): + raise ValueError("set_range_values values rows must not be empty.") + expected_width = len(op.values[0]) + if any(len(row) != expected_width for row in op.values): + raise ValueError("set_range_values requires rectangular values.") + + +def _validate_fill_formula(op: PatchOp) -> None: + """Validate fill_formula operation.""" + _validate_no_design_fields(op, op_name="fill_formula") + if op.cell is not None: + raise ValueError("fill_formula does not accept cell.") + if op.expected is not None: + raise ValueError("fill_formula does not accept expected.") + if op.value is not None: + raise ValueError("fill_formula does not accept value.") + if op.values is not None: + raise ValueError("fill_formula does not accept values.") + if op.range is None: + raise ValueError("fill_formula requires range.") + if op.base_cell is None: + raise ValueError("fill_formula requires base_cell.") + if op.formula is None: + raise ValueError("fill_formula requires formula.") + if not op.formula.startswith("="): + raise ValueError("fill_formula requires formula starting with '='.") + + +def _validate_set_value_if(op: PatchOp) -> None: + """Validate set_value_if operation.""" + _validate_no_design_fields(op, op_name="set_value_if") + if op.formula is not None: + raise ValueError("set_value_if does not accept formula.") + if op.range is not None: + raise ValueError("set_value_if does not accept range.") + if op.values is not None: + raise ValueError("set_value_if does not accept values.") + if op.base_cell is not None: + raise ValueError("set_value_if does not accept base_cell.") + + +def _validate_set_formula_if(op: PatchOp) -> None: + """Validate set_formula_if operation.""" + _validate_no_design_fields(op, op_name="set_formula_if") + if op.value is not None: + raise ValueError("set_formula_if does not accept value.") + if op.range is not None: + raise ValueError("set_formula_if does not accept range.") + if op.values is not None: + raise ValueError("set_formula_if does not accept values.") + if op.base_cell is not None: + raise ValueError("set_formula_if does not accept base_cell.") + if op.formula is None: + raise ValueError("set_formula_if requires formula.") + if not op.formula.startswith("="): + raise ValueError("set_formula_if requires formula starting with '='.") + + +def _validate_draw_grid_border(op: PatchOp) -> None: + """Validate draw_grid_border operation.""" + _validate_no_legacy_edit_fields(op, op_name="draw_grid_border") + if op.cell is not None or op.range is not None: + raise ValueError("draw_grid_border does not accept cell or range.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("draw_grid_border does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("draw_grid_border does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("draw_grid_border does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("draw_grid_border does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("draw_grid_border does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="draw_grid_border") + if op.base_cell is None: + raise ValueError("draw_grid_border requires base_cell.") + if op.row_count is None or op.col_count is None: + raise ValueError("draw_grid_border requires row_count and col_count.") + if op.row_count < 1 or op.col_count < 1: + raise ValueError("draw_grid_border requires row_count >= 1 and col_count >= 1.") + if op.row_count * op.col_count > _MAX_STYLE_TARGET_CELLS: + raise ValueError( + f"draw_grid_border target exceeds max cells: {_MAX_STYLE_TARGET_CELLS}." + ) + + +def _validate_set_bold(op: PatchOp) -> None: + """Validate set_bold operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_bold") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_bold does not accept row_count or col_count.") + if op.color is not None or op.fill_color is not None: + raise ValueError("set_bold does not accept color or fill_color.") + if op.font_size is not None: + raise ValueError("set_bold does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_bold does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_bold does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_bold does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_bold") + _validate_exactly_one_cell_or_range(op, op_name="set_bold") + if op.bold is None: + op.bold = True + _validate_style_target_size(op, op_name="set_bold") + + +def _validate_set_font_size(op: PatchOp) -> None: + """Validate set_font_size operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_font_size") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_font_size does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_font_size does not accept bold, color, or fill_color.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_font_size does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_font_size does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_font_size does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_font_size") + _validate_exactly_one_cell_or_range(op, op_name="set_font_size") + if op.font_size is None: + raise ValueError("set_font_size requires font_size.") + if op.font_size <= 0: + raise ValueError("set_font_size font_size must be > 0.") + _validate_style_target_size(op, op_name="set_font_size") + + +def _validate_set_font_color(op: PatchOp) -> None: + """Validate set_font_color operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_font_color") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_font_color does not accept row_count or col_count.") + if op.bold is not None: + raise ValueError("set_font_color does not accept bold.") + if op.font_size is not None: + raise ValueError("set_font_color does not accept font_size.") + if op.fill_color is not None: + raise ValueError("set_font_color does not accept fill_color.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_font_color does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_font_color does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_font_color does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_font_color") + _validate_exactly_one_cell_or_range(op, op_name="set_font_color") + if op.color is None: + raise ValueError("set_font_color requires color.") + _validate_style_target_size(op, op_name="set_font_color") + + +def _validate_set_fill_color(op: PatchOp) -> None: + """Validate set_fill_color operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_fill_color") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_fill_color does not accept row_count or col_count.") + if op.bold is not None: + raise ValueError("set_fill_color does not accept bold.") + if op.color is not None: + raise ValueError("set_fill_color does not accept color.") + if op.font_size is not None: + raise ValueError("set_fill_color does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_fill_color does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_fill_color does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_fill_color does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_fill_color") + _validate_exactly_one_cell_or_range(op, op_name="set_fill_color") + if op.fill_color is None: + raise ValueError("set_fill_color requires fill_color.") + _validate_style_target_size(op, op_name="set_fill_color") + + +def _validate_set_dimensions(op: PatchOp) -> None: + """Validate set_dimensions operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_dimensions") + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError("set_dimensions does not accept cell/range/base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_dimensions does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_dimensions does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("set_dimensions does not accept font_size.") + if op.design_snapshot is not None: + raise ValueError("set_dimensions does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_dimensions") + has_rows = op.rows is not None + has_columns = op.columns is not None + if not has_rows and not has_columns: + raise ValueError("set_dimensions requires rows and/or columns.") + if has_rows and op.row_height is None: + raise ValueError("set_dimensions requires row_height when rows is provided.") + if has_columns and op.column_width is None: + raise ValueError( + "set_dimensions requires column_width when columns is provided." + ) + if op.row_height is not None and op.row_height <= 0: + raise ValueError("set_dimensions row_height must be > 0.") + if op.column_width is not None and op.column_width <= 0: + raise ValueError("set_dimensions column_width must be > 0.") + + +def _validate_auto_fit_columns(op: PatchOp) -> None: + """Validate auto_fit_columns operation.""" + _validate_no_legacy_edit_fields( + op, op_name="auto_fit_columns", allow_auto_fit_fields=True + ) + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError("auto_fit_columns does not accept cell/range/base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("auto_fit_columns does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("auto_fit_columns does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("auto_fit_columns does not accept font_size.") + if op.rows is not None or op.row_height is not None or op.column_width is not None: + raise ValueError( + "auto_fit_columns does not accept rows, row_height, or column_width." + ) + if op.design_snapshot is not None: + raise ValueError("auto_fit_columns does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="auto_fit_columns") + if ( + op.min_width is not None + and op.max_width is not None + and op.min_width > op.max_width + ): + raise ValueError("auto_fit_columns requires min_width <= max_width.") + + +def _validate_merge_cells(op: PatchOp) -> None: + """Validate merge_cells operation.""" + _validate_no_legacy_edit_fields(op, op_name="merge_cells") + if op.cell is not None or op.base_cell is not None: + raise ValueError("merge_cells does not accept cell or base_cell.") + if op.range is None: + raise ValueError("merge_cells requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("merge_cells does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("merge_cells does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("merge_cells does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("merge_cells does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("merge_cells does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("merge_cells does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="merge_cells") + if _range_cell_count(op.range) < 2: + raise ValueError("merge_cells requires a multi-cell range.") + + +def _validate_unmerge_cells(op: PatchOp) -> None: + """Validate unmerge_cells operation.""" + _validate_no_legacy_edit_fields(op, op_name="unmerge_cells") + if op.cell is not None or op.base_cell is not None: + raise ValueError("unmerge_cells does not accept cell or base_cell.") + if op.range is None: + raise ValueError("unmerge_cells requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("unmerge_cells does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("unmerge_cells does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("unmerge_cells does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("unmerge_cells does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("unmerge_cells does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("unmerge_cells does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="unmerge_cells") + + +def _validate_set_alignment(op: PatchOp) -> None: + """Validate set_alignment operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_alignment") + if op.base_cell is not None: + raise ValueError("set_alignment does not accept base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_alignment does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_alignment does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("set_alignment does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_alignment does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_alignment does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_alignment does not accept design_snapshot.") + _validate_exactly_one_cell_or_range(op, op_name="set_alignment") + if ( + op.horizontal_align is None + and op.vertical_align is None + and op.wrap_text is None + ): + raise ValueError( + "set_alignment requires at least one of horizontal_align, vertical_align, or wrap_text." + ) + _validate_style_target_size(op, op_name="set_alignment") + + +def _validate_set_style(op: PatchOp) -> None: + """Validate set_style operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_style") + if op.base_cell is not None: + raise ValueError("set_style does not accept base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_style does not accept row_count or col_count.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_style does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_style does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_style does not accept design_snapshot.") + _validate_exactly_one_cell_or_range(op, op_name="set_style") + if ( + op.bold is None + and op.font_size is None + and op.color is None + and op.fill_color is None + and op.horizontal_align is None + and op.vertical_align is None + and op.wrap_text is None + ): + raise ValueError( + "set_style requires at least one style field from: " + "bold, font_size, color, fill_color, horizontal_align, vertical_align, wrap_text." + ) + if op.font_size is not None and op.font_size <= 0: + raise ValueError("set_style font_size must be > 0.") + _validate_style_target_size(op, op_name="set_style") + + +def _validate_apply_table_style(op: PatchOp) -> None: + """Validate apply_table_style operation.""" + _validate_no_legacy_edit_fields( + op, op_name="apply_table_style", allow_table_fields=True + ) + if op.cell is not None or op.base_cell is not None: + raise ValueError("apply_table_style does not accept cell or base_cell.") + if op.range is None: + raise ValueError("apply_table_style requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("apply_table_style does not accept row_count or col_count.") + if ( + op.bold is not None + or op.color is not None + or op.fill_color is not None + or op.font_size is not None + ): + raise ValueError( + "apply_table_style does not accept bold, color, fill_color, or font_size." + ) + if op.rows is not None or op.columns is not None: + raise ValueError("apply_table_style does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError( + "apply_table_style does not accept row_height or column_width." + ) + _validate_no_alignment_fields(op, op_name="apply_table_style") + if op.design_snapshot is not None: + raise ValueError("apply_table_style does not accept design_snapshot.") + if op.style is None: + raise ValueError("apply_table_style requires style.") + + +def _validate_restore_design_snapshot(op: PatchOp) -> None: + """Validate restore_design_snapshot operation.""" + _validate_no_legacy_edit_fields(op, op_name="restore_design_snapshot") + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError( + "restore_design_snapshot does not accept cell/range/base_cell." + ) + if op.row_count is not None or op.col_count is not None: + raise ValueError( + "restore_design_snapshot does not accept row_count or col_count." + ) + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError( + "restore_design_snapshot does not accept bold, color, or fill_color." + ) + if op.font_size is not None: + raise ValueError("restore_design_snapshot does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("restore_design_snapshot does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError( + "restore_design_snapshot does not accept row_height or column_width." + ) + _validate_no_alignment_fields(op, op_name="restore_design_snapshot") + if op.design_snapshot is None: + raise ValueError("restore_design_snapshot requires design_snapshot.") + + +def _validate_no_legacy_edit_fields( + op: PatchOp, + *, + op_name: str, + allow_table_fields: bool = False, + allow_auto_fit_fields: bool = False, +) -> None: + """Reject fields that are unrelated to design operations.""" + if op.expected is not None: + raise ValueError(f"{op_name} does not accept expected.") + if op.value is not None: + raise ValueError(f"{op_name} does not accept value.") + if op.values is not None: + raise ValueError(f"{op_name} does not accept values.") + if op.formula is not None: + raise ValueError(f"{op_name} does not accept formula.") + if not allow_table_fields: + if op.style is not None: + raise ValueError(f"{op_name} does not accept style.") + if op.table_name is not None: + raise ValueError(f"{op_name} does not accept table_name.") + if not allow_auto_fit_fields: + if op.min_width is not None: + raise ValueError(f"{op_name} does not accept min_width.") + if op.max_width is not None: + raise ValueError(f"{op_name} does not accept max_width.") + + +def _validate_no_design_fields(op: PatchOp, *, op_name: str) -> None: + """Reject design-only fields for legacy value edit operations.""" + if op.row_count is not None or op.col_count is not None: + raise ValueError(f"{op_name} does not accept row_count or col_count.") + if op.rows is not None or op.columns is not None: + raise ValueError(f"{op_name} does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError(f"{op_name} does not accept row_height or column_width.") + _reject_optional_field(op_name, "bold", op.bold) + _reject_optional_field(op_name, "color", op.color) + _reject_optional_field(op_name, "font_size", op.font_size) + _reject_optional_field(op_name, "fill_color", op.fill_color) + _reject_optional_field(op_name, "style", op.style) + _reject_optional_field(op_name, "table_name", op.table_name) + _validate_no_alignment_fields(op, op_name=op_name) + _reject_optional_field(op_name, "design_snapshot", op.design_snapshot) + _reject_optional_field(op_name, "min_width", op.min_width) + _reject_optional_field(op_name, "max_width", op.max_width) + + +def _reject_optional_field(op_name: str, field_name: str, value: object) -> None: + """Raise when an optional field is provided for an unsupported op.""" + if value is not None: + raise ValueError(f"{op_name} does not accept {field_name}.") + + +def _validate_no_alignment_fields(op: PatchOp, *, op_name: str) -> None: + """Reject alignment-only fields for unrelated operations.""" + if op.horizontal_align is not None: + raise ValueError(f"{op_name} does not accept horizontal_align.") + if op.vertical_align is not None: + raise ValueError(f"{op_name} does not accept vertical_align.") + if op.wrap_text is not None: + raise ValueError(f"{op_name} does not accept wrap_text.") + + +def _validate_exactly_one_cell_or_range(op: PatchOp, *, op_name: str) -> None: + """Ensure exactly one of cell/range is provided.""" + if op.base_cell is not None: + raise ValueError(f"{op_name} does not accept base_cell.") + has_cell = op.cell is not None + has_range = op.range is not None + if has_cell == has_range: + raise ValueError(f"{op_name} requires exactly one of cell or range.") + + +def _validate_style_target_size(op: PatchOp, *, op_name: str) -> None: + """Guard style edits against accidental huge targets.""" + target_count = 1 if op.cell is not None else _range_cell_count(op.range) + if target_count > _MAX_STYLE_TARGET_CELLS: + raise ValueError( + f"{op_name} target exceeds max cells: {_MAX_STYLE_TARGET_CELLS}." + ) + + +def _range_cell_count(range_ref: str | None) -> int: + """Return the number of cells represented by an A1 range.""" + if range_ref is None: + raise ValueError("range is required.") + return _shared_range_cell_count(range_ref) + + +def _split_a1(value: str) -> tuple[str, int]: + """Split A1 notation into normalized (column_label, row_index).""" + return _shared_split_a1(value) + + +def _normalize_column_identifier(value: str | int) -> str | int: + """Normalize a column identifier preserving letter/index semantics.""" + if isinstance(value, int): + if value < 1: + raise ValueError("columns numeric values must be positive.") + return value + label = value.strip().upper() + if not _COLUMN_LABEL_PATTERN.match(label): + raise ValueError(f"Invalid column identifier: {value}") + return label + + +def _column_label_to_index(label: str) -> int: + """Convert Excel-style column label (A/AA) to 1-based index.""" + return _shared_column_label_to_index(label) + + +def _column_index_to_label(index: int) -> str: + """Convert 1-based column index to Excel-style column label.""" + return _shared_column_index_to_label(index) + + +class PatchValue(BaseModel): + """Normalized before/after value in patch diff.""" + + kind: PatchValueKind + value: str | int | float | None + + +class PatchDiffItem(BaseModel): + """Applied change record for patch operations.""" + + op_index: int + op: PatchOpType + sheet: str + cell: str | None = None + before: PatchValue | None = None + after: PatchValue | None = None + status: PatchStatus = "applied" + + +class PatchErrorDetail(BaseModel): + """Structured error details for patch failures.""" + + op_index: int + op: PatchOpType + sheet: str + cell: str | None + message: str + hint: str | None = None + expected_fields: list[str] = Field(default_factory=list) + example_op: str | None = None + + +class FormulaIssue(BaseModel): + """Formula health-check finding.""" + + sheet: str + cell: str + level: FormulaIssueLevel + code: FormulaIssueCode + message: str + + +class PatchRequest(BaseModel): + """Input model for ExStruct MCP patch.""" + + xlsx_path: Path + ops: list[PatchOp] + sheet: str | None = None + out_dir: Path | None = None + out_name: str | None = None + on_conflict: OnConflictPolicy = "overwrite" + auto_formula: bool = False + dry_run: bool = False + return_inverse_ops: bool = False + preflight_formula_check: bool = False + backend: PatchBackend = "auto" + + @model_validator(mode="after") + def _validate_backend_constraints(self) -> PatchRequest: + if self.backend != "com": + return self + if self.dry_run or self.return_inverse_ops or self.preflight_formula_check: + raise ValueError( + "backend='com' does not support dry_run, return_inverse_ops, " + "or preflight_formula_check." + ) + if any(op.op == "restore_design_snapshot" for op in self.ops): + raise ValueError( + "backend='com' does not support restore_design_snapshot operation." + ) + return self + + +class MakeRequest(BaseModel): + """Input model for ExStruct MCP workbook creation.""" + + out_path: Path + ops: list[PatchOp] = Field(default_factory=list) + sheet: str | None = None + on_conflict: OnConflictPolicy = "overwrite" + auto_formula: bool = False + dry_run: bool = False + return_inverse_ops: bool = False + preflight_formula_check: bool = False + backend: PatchBackend = "auto" + + @model_validator(mode="after") + def _validate_backend_constraints(self) -> MakeRequest: + if self.backend != "com": + return self + if self.dry_run or self.return_inverse_ops or self.preflight_formula_check: + raise ValueError( + "backend='com' does not support dry_run, return_inverse_ops, " + "or preflight_formula_check." + ) + if any(op.op == "restore_design_snapshot" for op in self.ops): + raise ValueError( + "backend='com' does not support restore_design_snapshot operation." + ) + return self + + +class PatchResult(BaseModel): + """Output model for ExStruct MCP patch.""" + + out_path: str + patch_diff: list[PatchDiffItem] = Field(default_factory=list) + inverse_ops: list[PatchOp] = Field(default_factory=list) + formula_issues: list[FormulaIssue] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + error: PatchErrorDetail | None = None + engine: PatchEngine + + +def run_make(request: MakeRequest, *, policy: PathPolicy | None = None) -> PatchResult: + """Create a new workbook and apply patch operations in one call. + + Args: + request: Workbook creation request payload. + policy: Optional path policy for access control. + + Returns: + Patch-compatible result with output path and diff. + + Raises: + ValueError: If request validation fails. + RuntimeError: If backend operations fail. + """ + from .service import run_make as _service_run_make + + return cast(PatchResult, _service_run_make(cast(Any, request), policy=policy)) + + +def run_patch( + request: PatchRequest, *, policy: PathPolicy | None = None +) -> PatchResult: + """Run a patch operation and write the updated workbook. + + Args: + request: Patch request payload. + policy: Optional path policy for access control. + + Returns: + Patch result with output path and diff. + + Raises: + FileNotFoundError: If the input file does not exist. + ValueError: If validation fails or the path violates policy. + RuntimeError: If a backend operation fails. + """ + from .service import run_patch as _service_run_patch + + return cast(PatchResult, _service_run_patch(cast(Any, request), policy=policy)) + + +def _apply_with_openpyxl( + request: PatchRequest, + input_path: Path, + output_path: Path, + warnings: list[str], +) -> PatchResult: + """Apply patch operations using openpyxl.""" + try: + diff, inverse_ops, formula_issues, op_warnings = _apply_ops_openpyxl( + request, + input_path, + output_path, + ) + except PatchOpError as exc: + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=[], + warnings=warnings, + error=exc.detail, + engine="openpyxl", + ) + except ValueError: + raise + except FileNotFoundError: + raise + except OSError: + raise + except Exception as exc: + raise RuntimeError(f"openpyxl patch failed: {exc}") from exc + + warnings.extend(op_warnings) + if not request.dry_run: + warnings.append( + "openpyxl editing may drop shapes/charts or unsupported elements." + ) + _append_skip_warnings(warnings, diff) + if ( + not request.dry_run + and request.preflight_formula_check + and any(issue.level == "error" for issue in formula_issues) + ): + issue = formula_issues[0] + op_index, op_name = _find_preflight_issue_origin(issue, request.ops) + error = PatchErrorDetail( + op_index=op_index, + op=op_name, + sheet=issue.sheet, + cell=issue.cell, + message=f"Formula health check failed: {issue.message}", + hint=None, + expected_fields=[], + example_op=None, + ) + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=formula_issues, + warnings=warnings, + error=error, + engine="openpyxl", + ) + return PatchResult( + out_path=str(output_path), + patch_diff=diff, + inverse_ops=inverse_ops, + formula_issues=formula_issues, + warnings=warnings, + engine="openpyxl", + ) + + +def _append_skip_warnings(warnings: list[str], diff: list[PatchDiffItem]) -> None: + """Append warning messages for skipped conditional operations.""" + for item in diff: + if item.status != "skipped": + continue + warnings.append( + f"Skipped op[{item.op_index}] {item.op} at {item.sheet}!{item.cell} due to condition mismatch." + ) + + +def _find_preflight_issue_origin( + issue: FormulaIssue, ops: list[PatchOp] +) -> tuple[int, PatchOpType]: + """Find the most likely op index/op name for a preflight formula issue.""" + for index, op in enumerate(ops): + if _op_targets_issue_cell(op, issue.sheet, issue.cell): + return index, op.op + return -1, "set_value" + + +def _op_targets_issue_cell(op: PatchOp, sheet: str, cell: str) -> bool: + """Return True when an op can affect the specified sheet/cell.""" + if op.sheet != sheet: + return False + if op.cell is not None: + return op.cell == cell + if op.range is None: + return False + for row in _expand_range_coordinates(op.range): + if cell in row: + return True + return False + + +def _allow_auto_openpyxl_fallback(request: PatchRequest, input_path: Path) -> bool: + """Return True when COM failure can fallback to openpyxl.""" + if request.backend != "auto": + return False + return input_path.suffix.lower() in {".xlsx", ".xlsm"} + + +def _requires_openpyxl_backend(request: PatchRequest) -> bool: + """Return True if request requires openpyxl backend for extended features.""" + if request.dry_run or request.return_inverse_ops or request.preflight_formula_check: + return True + return any(op.op == "restore_design_snapshot" for op in request.ops) + + +def _select_patch_engine( + *, request: PatchRequest, input_path: Path, com_available: bool +) -> PatchEngine: + """Select concrete patch engine based on request and environment.""" + extension = input_path.suffix.lower() + if request.backend == "openpyxl": + if extension == ".xls": + raise ValueError("backend='openpyxl' cannot edit .xls files.") + return "openpyxl" + if request.backend == "com": + if not com_available: + raise ValueError("backend='com' requires Windows Excel COM availability.") + return "com" + if extension == ".xls": + if not com_available: + raise ValueError( + ".xls editing requires Windows Excel COM (xlwings) in this environment." + ) + return "com" + if _requires_openpyxl_backend(request): + return "openpyxl" + if com_available: + return "com" + return "openpyxl" + + +def _contains_design_ops(ops: list[PatchOp]) -> bool: + """Return True when any style/dimension design operation is present.""" + design_ops = { + "draw_grid_border", + "set_bold", + "set_font_size", + "set_font_color", + "set_fill_color", + "set_dimensions", + "auto_fit_columns", + "merge_cells", + "unmerge_cells", + "set_alignment", + "set_style", + "apply_table_style", + "restore_design_snapshot", + } + return any(op.op in design_ops for op in ops) + + +def _contains_apply_table_style_op(ops: list[PatchOp]) -> bool: + """Return True when apply_table_style is present.""" + return any(op.op == "apply_table_style" for op in ops) + + +def _append_large_ops_warning(warnings: list[str], ops: list[PatchOp]) -> None: + """Append warning when operation count exceeds the soft threshold.""" + if len(ops) <= _SOFT_MAX_OPS_WARNING_THRESHOLD: + return + warnings.append( + "Large patch request: " + f"{len(ops)} ops. Recommended maximum is " + f"{_SOFT_MAX_OPS_WARNING_THRESHOLD}; consider splitting into batches." + ) + + +def _resolve_input_path(path: Path, *, policy: PathPolicy | None) -> Path: + """Resolve and validate the input path.""" + resolved = policy.ensure_allowed(path) if policy else path.resolve() + if not resolved.exists(): + raise FileNotFoundError(f"Input file not found: {resolved}") + if not resolved.is_file(): + raise ValueError(f"Input path is not a file: {resolved}") + return resolved + + +def _ensure_supported_extension(path: Path) -> None: + """Validate that the input file extension is supported.""" + if path.suffix.lower() not in _ALLOWED_EXTENSIONS: + raise ValueError(f"Unsupported file extension: {path.suffix}") + + +def _resolve_output_path( + input_path: Path, + *, + out_dir: Path | None, + out_name: str | None, + policy: PathPolicy | None, +) -> Path: + """Build and validate the output path.""" + return _shared_resolve_output_path( + input_path, + out_dir=out_dir, + out_name=out_name, + policy=policy, + default_suffix=input_path.suffix, + default_name_builder="patched", + ) + + +def _resolve_make_output_path(path: Path, *, policy: PathPolicy | None) -> Path: + """Resolve and validate output path for workbook creation.""" + resolved = policy.ensure_allowed(path) if policy else path.resolve() + if resolved.exists() and resolved.is_dir(): + raise ValueError(f"Output path is a directory: {resolved}") + return resolved + + +def _validate_make_request_constraints(request: MakeRequest, output_path: Path) -> None: + """Validate make-specific constraints by output extension.""" + if output_path.suffix.lower() != ".xls": + return + if request.backend == "openpyxl": + raise ValueError("backend='openpyxl' cannot edit .xls files.") + if request.dry_run or request.return_inverse_ops or request.preflight_formula_check: + raise ValueError( + ".xls creation does not support dry_run, return_inverse_ops, " + "or preflight_formula_check." + ) + com = get_com_availability() + if not com.available: + raise ValueError( + ".xls editing requires Windows Excel COM (xlwings) in this environment." + ) + + +def _build_make_seed_path(output_path: Path) -> Path: + """Return a temporary seed path in the target output directory.""" + seed_name = f".exstruct_make_seed_{uuid4().hex}{output_path.suffix.lower()}" + return output_path.parent / seed_name + + +def _resolve_make_initial_sheet_name(request: MakeRequest) -> str: + """Resolve initial sheet name for `exstruct_make` seed workbook.""" + if request.sheet is None: + return "Sheet1" + requested_sheet = request.sheet.strip() + if not requested_sheet: + return "Sheet1" + normalized_requested_sheet = _normalize_sheet_name_for_make_conflict( + requested_sheet + ) + has_conflicting_add_sheet = any( + op.op == "add_sheet" + and _normalize_sheet_name_for_make_conflict(op.sheet) + == normalized_requested_sheet + for op in request.ops + ) + if has_conflicting_add_sheet: + return "Sheet1" + return requested_sheet + + +def _normalize_sheet_name_for_make_conflict(sheet_name: str) -> str: + """Normalize sheet name text for make-time conflict detection.""" + return sheet_name.strip().casefold() + + +def _create_seed_workbook( + seed_path: Path, extension: str, *, initial_sheet_name: str +) -> None: + """Create an empty workbook seed with the resolved initial sheet name.""" + _ensure_output_dir(seed_path) + if extension == ".xls": + _create_xls_seed_with_com(seed_path, initial_sheet_name=initial_sheet_name) + return + _create_openpyxl_seed(seed_path, initial_sheet_name=initial_sheet_name) + + +def _create_openpyxl_seed(seed_path: Path, *, initial_sheet_name: str) -> None: + """Create an empty workbook via openpyxl.""" + try: + from openpyxl import Workbook + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + workbook = Workbook() + try: + active_sheet = workbook.active + if active_sheet is None: + raise RuntimeError("Failed to create default worksheet.") + active_sheet.title = initial_sheet_name + workbook.save(seed_path) + finally: + workbook.close() + + +def _create_xls_seed_with_com(seed_path: Path, *, initial_sheet_name: str) -> None: + """Create an empty .xls workbook via Excel COM.""" + com = get_com_availability() + if not com.available: + raise ValueError( + ".xls editing requires Windows Excel COM (xlwings) in this environment." + ) + app = xw.App(add_book=False, visible=False) + app.display_alerts = False + app.screen_updating = False + workbook = app.books.add() + try: + workbook.sheets[0].name = initial_sheet_name + workbook.save(str(seed_path)) + except Exception as exc: + raise RuntimeError(f"COM workbook creation failed: {exc}") from exc + finally: + _close_workbook_safely(workbook) + _quit_app_safely(app) + + +def _normalize_output_name(input_path: Path, out_name: str | None) -> str: + """Normalize output filename with a safe suffix.""" + if out_name: + candidate = Path(out_name) + return ( + candidate.name + if candidate.suffix + else f"{candidate.name}{input_path.suffix}" + ) + return f"{input_path.stem}_patched{input_path.suffix}" + + +def _ensure_output_dir(path: Path) -> None: + """Ensure the output directory exists before writing.""" + path.parent.mkdir(parents=True, exist_ok=True) + + +def _apply_conflict_policy( + output_path: Path, on_conflict: OnConflictPolicy +) -> tuple[Path, str | None, bool]: + """Apply output conflict policy to a resolved output path.""" + return _shared_apply_conflict_policy(output_path, on_conflict) + + +def _next_available_path(path: Path) -> Path: + """Return the next available path by appending a numeric suffix.""" + return _shared_next_available_path(path) + + +def _apply_ops_openpyxl( + request: PatchRequest, + input_path: Path, + output_path: Path, +) -> tuple[list[PatchDiffItem], list[PatchOp], list[FormulaIssue], list[str]]: + """Apply operations using openpyxl.""" + try: + from openpyxl import load_workbook + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + + if input_path.suffix.lower() == ".xls": + raise ValueError("openpyxl cannot edit .xls files.") + + if input_path.suffix.lower() == ".xlsm": + workbook = load_workbook(input_path, keep_vba=True) + else: + workbook = load_workbook(input_path) + try: + diff, inverse_ops, op_warnings = _apply_ops_to_openpyxl_workbook( + workbook, + request.ops, + request.auto_formula, + return_inverse_ops=request.return_inverse_ops, + ) + formula_issues = ( + _collect_formula_issues_openpyxl(workbook) + if request.preflight_formula_check + else [] + ) + if not request.dry_run and not ( + request.preflight_formula_check + and any(issue.level == "error" for issue in formula_issues) + ): + workbook.save(output_path) + finally: + workbook.close() + return diff, inverse_ops, formula_issues, op_warnings + + +def _apply_ops_to_openpyxl_workbook( + workbook: OpenpyxlWorkbookProtocol, + ops: list[PatchOp], + auto_formula: bool, + *, + return_inverse_ops: bool, +) -> tuple[list[PatchDiffItem], list[PatchOp], list[str]]: + """Apply ops to an openpyxl workbook instance.""" + sheets = _openpyxl_sheet_map(workbook) + diff: list[PatchDiffItem] = [] + inverse_ops: list[PatchOp] = [] + op_warnings: list[str] = [] + for index, op in enumerate(ops): + try: + item, inverse = _apply_openpyxl_op( + workbook, sheets, op, index, auto_formula, op_warnings + ) + diff.append(item) + if return_inverse_ops and item.status == "applied" and inverse is not None: + inverse_ops.append(inverse) + except ValueError as exc: + raise PatchOpError.from_op(index, op, exc) from exc + if return_inverse_ops: + inverse_ops.reverse() + return diff, inverse_ops, op_warnings + + +def _openpyxl_sheet_map( + workbook: OpenpyxlWorkbookProtocol, +) -> dict[str, OpenpyxlWorksheetProtocol]: + """Build a sheet map for openpyxl workbooks.""" + sheet_names = getattr(workbook, "sheetnames", None) + if not isinstance(sheet_names, list): + raise ValueError("Invalid workbook: sheetnames missing.") + return {name: workbook[name] for name in sheet_names} + + +def _apply_openpyxl_op( + workbook: OpenpyxlWorkbookProtocol, + sheets: dict[str, OpenpyxlWorksheetProtocol], + op: PatchOp, + index: int, + auto_formula: bool, + warnings: list[str], +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply a single op to openpyxl workbook.""" + if op.op == "add_sheet": + return _apply_openpyxl_add_sheet(workbook, sheets, op, index) + + existing_sheet = sheets.get(op.sheet) + if existing_sheet is None: + raise ValueError(f"Sheet not found: {op.sheet}") + return _apply_openpyxl_sheet_op( + existing_sheet, + op, + index, + auto_formula=auto_formula, + warnings=warnings, + ) + + +def _apply_openpyxl_sheet_op( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, + *, + auto_formula: bool, + warnings: list[str], +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply openpyxl operation that targets an existing sheet.""" + if op.op in {"set_value", "set_formula", "set_value_if", "set_formula_if"}: + return _apply_openpyxl_cell_op(sheet, op, index, auto_formula) + handlers: dict[PatchOpType, Callable[[], tuple[PatchDiffItem, PatchOp | None]]] = { + "set_range_values": lambda: _apply_openpyxl_set_range_values(sheet, op, index), + "fill_formula": lambda: _apply_openpyxl_fill_formula(sheet, op, index), + "draw_grid_border": lambda: _apply_openpyxl_draw_grid_border(sheet, op, index), + "set_bold": lambda: _apply_openpyxl_set_bold(sheet, op, index), + "set_font_size": lambda: _apply_openpyxl_set_font_size(sheet, op, index), + "set_font_color": lambda: _apply_openpyxl_set_font_color(sheet, op, index), + "set_fill_color": lambda: _apply_openpyxl_set_fill_color(sheet, op, index), + "set_dimensions": lambda: _apply_openpyxl_set_dimensions(sheet, op, index), + "auto_fit_columns": lambda: _apply_openpyxl_auto_fit_columns(sheet, op, index), + "merge_cells": lambda: _apply_openpyxl_merge_cells(sheet, op, index, warnings), + "unmerge_cells": lambda: _apply_openpyxl_unmerge_cells(sheet, op, index), + "set_alignment": lambda: _apply_openpyxl_set_alignment(sheet, op, index), + "set_style": lambda: _apply_openpyxl_set_style(sheet, op, index), + "apply_table_style": lambda: _apply_openpyxl_apply_table_style( + sheet, op, index + ), + "restore_design_snapshot": lambda: _apply_openpyxl_restore_design_snapshot( + sheet, op, index + ), + } + handler = handlers.get(op.op) + if handler is None: + raise ValueError(f"Unsupported op: {op.op}") + return handler() + + +def _apply_openpyxl_add_sheet( + workbook: OpenpyxlWorkbookProtocol, + sheets: dict[str, OpenpyxlWorksheetProtocol], + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply add_sheet op.""" + if op.sheet in sheets: + raise ValueError(f"Sheet already exists: {op.sheet}") + sheet = workbook.create_sheet(title=op.sheet) + sheets[op.sheet] = sheet + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="sheet", value=op.sheet), + ), + None, + ) + + +def _apply_openpyxl_set_range_values( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_range_values op.""" + if op.range is None or op.values is None: + raise ValueError("set_range_values requires range and values.") + coordinates = _expand_range_coordinates(op.range) + rows, cols = _shape_of_coordinates(coordinates) + if len(op.values) != rows: + raise ValueError("set_range_values values height does not match range.") + if any(len(row) != cols for row in op.values): + raise ValueError("set_range_values values width does not match range.") + for r_idx, row in enumerate(coordinates): + for c_idx, coord in enumerate(row): + sheet[coord].value = op.values[r_idx][c_idx] + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="value", value=f"{rows}x{cols}"), + ), + None, + ) + + +def _apply_openpyxl_fill_formula( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply fill_formula op.""" + if op.range is None or op.formula is None or op.base_cell is None: + raise ValueError("fill_formula requires range, base_cell and formula.") + coordinates = _expand_range_coordinates(op.range) + rows, cols = _shape_of_coordinates(coordinates) + if rows != 1 and cols != 1: + raise ValueError("fill_formula range must be a single row or a single column.") + for row in coordinates: + for coord in row: + sheet[coord].value = _translate_formula(op.formula, op.base_cell, coord) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="formula", value=op.formula), + ), + None, + ) + + +def _apply_openpyxl_draw_grid_border( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply draw_grid_border op with thin black border.""" + if op.base_cell is None or op.row_count is None or op.col_count is None: + raise ValueError( + "draw_grid_border requires base_cell, row_count and col_count." + ) + coordinates = _expand_rect_coordinates(op.base_cell, op.row_count, op.col_count) + snapshot = DesignSnapshot( + borders=[_snapshot_border(sheet[coord], coord) for coord in coordinates] + ) + for coord in coordinates: + _set_grid_border(sheet[coord]) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=f"{op.base_cell}:{coordinates[-1]}", + before=None, + after=PatchValue(kind="style", value="grid_border(thin,black)"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_bold( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_bold op.""" + targets = _resolve_style_targets(op) + target_bold = True if op.bold is None else op.bold + snapshot = DesignSnapshot( + fonts=[_snapshot_font(sheet[coord], coord) for coord in targets] + ) + for coord in targets: + cell = sheet[coord] + font = copy(cell.font) + font.bold = target_bold + cell.font = font + location = op.cell if op.cell is not None else op.range + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=f"bold={target_bold}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_font_size( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_font_size op.""" + if op.font_size is None: + raise ValueError("set_font_size requires font_size.") + targets = _resolve_style_targets(op) + snapshot = DesignSnapshot( + fonts=[_snapshot_font(sheet[coord], coord) for coord in targets] + ) + for coord in targets: + cell = sheet[coord] + font = copy(cell.font) + font.size = op.font_size + cell.font = font + location = op.cell if op.cell is not None else op.range + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=f"font_size={op.font_size}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_font_color( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_font_color op.""" + if op.color is None: + raise ValueError("set_font_color requires color.") + targets = _resolve_style_targets(op) + snapshot = DesignSnapshot( + fonts=[_snapshot_font(sheet[coord], coord) for coord in targets] + ) + normalized = _normalize_hex_color(op.color) + for coord in targets: + cell = sheet[coord] + font = copy(cell.font) + font.color = normalized + cell.font = font + location = op.cell if op.cell is not None else op.range + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=f"font_color={op.color}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_fill_color( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_fill_color op.""" + if op.fill_color is None: + raise ValueError("set_fill_color requires fill_color.") + try: + from openpyxl.styles import PatternFill + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + + targets = _resolve_style_targets(op) + snapshot = DesignSnapshot( + fills=[_snapshot_fill(sheet[coord], coord) for coord in targets] + ) + normalized = _normalize_hex_color(op.fill_color) + for coord in targets: + sheet[coord].fill = PatternFill( + fill_type="solid", + start_color=normalized, + end_color=normalized, + ) + location = op.cell if op.cell is not None else op.range + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=f"fill={op.fill_color}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_dimensions( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_dimensions op.""" + snapshot = DesignSnapshot() + parts: list[str] = [] + if op.rows is not None and op.row_height is not None: + for row in op.rows: + row_dimension = sheet.row_dimensions[row] + snapshot.row_dimensions.append( + RowDimensionSnapshot( + row=row, + height=getattr(row_dimension, "height", None), + ) + ) + row_dimension.height = op.row_height + parts.append(f"rows={_summarize_int_targets(op.rows)}") + if op.columns is not None and op.column_width is not None: + normalized_columns = _normalize_columns_for_dimensions(op.columns) + for column in normalized_columns: + column_dimension = sheet.column_dimensions[column] + snapshot.column_dimensions.append( + ColumnDimensionSnapshot( + column=column, + width=getattr(column_dimension, "width", None), + ) + ) + column_dimension.width = op.column_width + parts.append(f"columns={_summarize_column_targets(normalized_columns)}") + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="dimension", value=", ".join(parts)), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_auto_fit_columns( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply auto_fit_columns op using openpyxl text-length estimation.""" + target_columns = _resolve_auto_fit_columns_openpyxl(sheet, op.columns) + if not target_columns: + raise ValueError("auto_fit_columns could not resolve target columns.") + target_column_indexes = { + _column_label_to_index(column) for column in target_columns + } + max_lengths = _collect_openpyxl_target_column_max_lengths( + sheet, target_column_indexes + ) + snapshot = DesignSnapshot() + for column in target_columns: + column_dimension = sheet.column_dimensions[column] + snapshot.column_dimensions.append( + ColumnDimensionSnapshot( + column=column, + width=getattr(column_dimension, "width", None), + ) + ) + max_len = max_lengths.get(_column_label_to_index(column), 0) + estimated_width = _resolve_openpyxl_estimated_width(column_dimension, max_len) + column_dimension.width = _clamp_column_width( + estimated_width, min_width=op.min_width, max_width=op.max_width + ) + parts = [f"columns={_summarize_column_targets(target_columns)}"] + if op.min_width is not None: + parts.append(f"min_width={op.min_width}") + if op.max_width is not None: + parts.append(f"max_width={op.max_width}") + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="dimension", value=", ".join(parts)), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_merge_cells( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, + warnings: list[str], +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply merge_cells op.""" + if op.range is None: + raise ValueError("merge_cells requires range.") + overlapped = _intersecting_merged_ranges(sheet, op.range) + if overlapped: + raise ValueError( + "merge_cells range overlaps existing merged ranges: " + + ", ".join(overlapped) + + "." + ) + merge_warning = _build_merge_value_loss_warning(sheet, op.sheet, op.range) + if merge_warning is not None: + warnings.append(merge_warning) + snapshot = DesignSnapshot( + merge_state=MergeStateSnapshot(scope=op.range, ranges=[]), + ) + sheet.merge_cells(op.range) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="style", value=f"merged={op.range}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_unmerge_cells( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply unmerge_cells op.""" + if op.range is None: + raise ValueError("unmerge_cells requires range.") + target_ranges = _intersecting_merged_ranges(sheet, op.range) + snapshot = DesignSnapshot( + merge_state=MergeStateSnapshot(scope=op.range, ranges=target_ranges), + ) + for range_ref in target_ranges: + sheet.unmerge_cells(range_ref) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="style", value=f"unmerged={len(target_ranges)}"), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_alignment( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_alignment op.""" + targets = _resolve_style_targets(op) + snapshot = DesignSnapshot( + alignments=[_snapshot_alignment(sheet[coord], coord) for coord in targets] + ) + for coord in targets: + cell = sheet[coord] + alignment = copy(cell.alignment) + if op.horizontal_align is not None: + alignment.horizontal = op.horizontal_align + if op.vertical_align is not None: + alignment.vertical = op.vertical_align + if op.wrap_text is not None: + alignment.wrap_text = op.wrap_text + cell.alignment = alignment + location = op.cell if op.cell is not None else op.range + summary = ( + f"horizontal={op.horizontal_align}," + f"vertical={op.vertical_align}," + f"wrap_text={op.wrap_text}" + ) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=summary), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_set_style( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply set_style op.""" + targets = _resolve_style_targets(op) + snapshot = DesignSnapshot( + fonts=[_snapshot_font(sheet[coord], coord) for coord in targets], + fills=[_snapshot_fill(sheet[coord], coord) for coord in targets], + alignments=[_snapshot_alignment(sheet[coord], coord) for coord in targets], + ) + font_color = _normalize_hex_color(op.color) if op.color is not None else None + fill_color = ( + _normalize_hex_color(op.fill_color) if op.fill_color is not None else None + ) + pattern_fill_factory: Callable[..., OpenpyxlFillProtocol] | None = None + if fill_color is not None: + try: + from openpyxl.styles import PatternFill + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + pattern_fill_factory = PatternFill + for coord in targets: + cell = sheet[coord] + font = copy(cell.font) + if op.bold is not None: + font.bold = op.bold + if op.font_size is not None: + font.size = op.font_size + if font_color is not None: + font.color = font_color + cell.font = font + if fill_color is not None and pattern_fill_factory is not None: + cell.fill = pattern_fill_factory( + fill_type="solid", + start_color=fill_color, + end_color=fill_color, + ) + if ( + op.horizontal_align is not None + or op.vertical_align is not None + or op.wrap_text is not None + ): + alignment = copy(cell.alignment) + if op.horizontal_align is not None: + alignment.horizontal = op.horizontal_align + if op.vertical_align is not None: + alignment.vertical = op.vertical_align + if op.wrap_text is not None: + alignment.wrap_text = op.wrap_text + cell.alignment = alignment + location = op.cell if op.cell is not None else op.range + parts = _build_set_style_summary_parts(op) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=location, + before=None, + after=PatchValue(kind="style", value=";".join(parts)), + ), + _build_restore_snapshot_op(op.sheet, snapshot), + ) + + +def _apply_openpyxl_apply_table_style( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply apply_table_style op.""" + if op.range is None or op.style is None: + raise ValueError("apply_table_style requires range and style.") + try: + from openpyxl.worksheet.table import Table, TableStyleInfo + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + _ensure_range_not_intersects_existing_tables(sheet, op.range) + table_name = op.table_name or _next_openpyxl_table_name(sheet) + _ensure_table_name_available(sheet, table_name) + table = Table(displayName=table_name, ref=op.range) + table.tableStyleInfo = TableStyleInfo( + name=op.style, + showFirstColumn=False, + showLastColumn=False, + showRowStripes=True, + showColumnStripes=False, + ) + add_table = getattr(sheet, "add_table", None) + if not callable(add_table): + raise ValueError("apply_table_style requires worksheet.add_table support.") + add_table(table) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue( + kind="style", + value=f"table={table_name};table_style={op.style}", + ), + ), + None, + ) + + +def _apply_openpyxl_restore_design_snapshot( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply restore_design_snapshot op.""" + if op.design_snapshot is None: + raise ValueError("restore_design_snapshot requires design_snapshot.") + _restore_design_snapshot(sheet, op.design_snapshot) + return ( + PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="style", value="design_snapshot_restored"), + ), + None, + ) + + +def _apply_openpyxl_cell_op( + sheet: OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, + auto_formula: bool, +) -> tuple[PatchDiffItem, PatchOp | None]: + """Apply single-cell operations.""" + cell_ref = op.cell + if cell_ref is None: + raise ValueError(f"{op.op} requires cell.") + cell = sheet[cell_ref] + before = _openpyxl_cell_value(cell) + + if op.op == "set_value": + after = _set_cell_value(cell, op.value, auto_formula, op_name="set_value") + return _build_cell_result( + op, index, cell_ref, before, after + ), _build_inverse_cell_op(op, cell_ref, before) + if op.op == "set_formula": + formula = _require_formula(op.formula, "set_formula") + cell.value = formula + after = PatchValue(kind="formula", value=formula) + return _build_cell_result( + op, index, cell_ref, before, after + ), _build_inverse_cell_op(op, cell_ref, before) + if op.op == "set_value_if": + if not _values_equal_for_condition( + _patch_value_to_primitive(before), op.expected + ): + return _build_skipped_result(op, index, cell_ref, before), None + after = _set_cell_value(cell, op.value, auto_formula, op_name="set_value_if") + return _build_cell_result( + op, index, cell_ref, before, after + ), _build_inverse_cell_op(op, cell_ref, before) + formula_if = _require_formula(op.formula, "set_formula_if") + if not _values_equal_for_condition(_patch_value_to_primitive(before), op.expected): + return _build_skipped_result(op, index, cell_ref, before), None + cell.value = formula_if + after = PatchValue(kind="formula", value=formula_if) + return _build_cell_result( + op, index, cell_ref, before, after + ), _build_inverse_cell_op(op, cell_ref, before) + + +def _set_cell_value( + cell: OpenpyxlCellProtocol, + value: str | int | float | None, + auto_formula: bool, + *, + op_name: str, +) -> PatchValue: + """Set cell value with auto_formula handling.""" + if isinstance(value, str) and value.startswith("="): + if not auto_formula: + raise ValueError(f"{op_name} rejects values starting with '='.") + cell.value = value + return PatchValue(kind="formula", value=value) + cell.value = value + return PatchValue(kind="value", value=value) + + +def _build_cell_result( + op: PatchOp, + index: int, + cell_ref: str, + before: PatchValue | None, + after: PatchValue | None, +) -> PatchDiffItem: + """Build applied diff item for single-cell op.""" + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=cell_ref, + before=before, + after=after, + ) + + +def _build_skipped_result( + op: PatchOp, + index: int, + cell_ref: str, + before: PatchValue | None, +) -> PatchDiffItem: + """Build skipped diff item.""" + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=cell_ref, + before=before, + after=before, + status="skipped", + ) + + +def _build_set_style_summary_parts(op: PatchOp) -> list[str]: + """Build summary parts for set_style diff output.""" + parts: list[str] = [] + if op.bold is not None: + parts.append(f"bold={op.bold}") + if op.font_size is not None: + parts.append(f"font_size={op.font_size}") + if op.color is not None: + parts.append(f"color={_normalize_hex_input(op.color, field_name='color')}") + if op.fill_color is not None: + parts.append( + f"fill_color={_normalize_hex_input(op.fill_color, field_name='fill_color')}" + ) + if op.horizontal_align is not None: + parts.append(f"horizontal_align={op.horizontal_align}") + if op.vertical_align is not None: + parts.append(f"vertical_align={op.vertical_align}") + if op.wrap_text is not None: + parts.append(f"wrap_text={op.wrap_text}") + return parts + + +def _ensure_range_not_intersects_existing_tables( + sheet: OpenpyxlWorksheetProtocol, range_ref: str +) -> None: + """Raise ValueError if range intersects with existing table ranges.""" + for table_name, existing_ref in _collect_openpyxl_table_ranges(sheet): + if _ranges_overlap(range_ref, existing_ref): + raise ValueError( + "apply_table_style range intersects existing table " + f"'{table_name}' ({existing_ref})." + ) + + +def _ensure_table_name_available( + sheet: OpenpyxlWorksheetProtocol, table_name: str +) -> None: + """Raise ValueError when table name already exists in sheet.""" + existing_names = {name for name, _ in _collect_openpyxl_table_ranges(sheet)} + if table_name in existing_names: + raise ValueError(f"Table name already exists: {table_name}") + + +def _next_openpyxl_table_name(sheet: OpenpyxlWorksheetProtocol) -> str: + """Generate next available table name like Table1, Table2, ...""" + existing_names = {name for name, _ in _collect_openpyxl_table_ranges(sheet)} + for index in range(1, 10_000): + candidate = f"Table{index}" + if candidate not in existing_names: + return candidate + raise RuntimeError("Failed to generate unique table name.") + + +def _collect_openpyxl_table_ranges( + sheet: OpenpyxlWorksheetProtocol, +) -> list[tuple[str, str]]: + """Collect (table_name, range_ref) pairs from worksheet tables.""" + tables = getattr(sheet, "tables", None) + if tables is None or not isinstance(tables, OpenpyxlTablesProtocol): + return [] + pairs: list[tuple[str, str]] = [] + for key, value in tables.items(): + table_name = str(getattr(value, "displayName", key)) + ref_raw = getattr(value, "ref", None) + if isinstance(ref_raw, str): + pairs.append((table_name, ref_raw)) + continue + if isinstance(value, str): + pairs.append((str(key), value)) + return pairs + + +def _require_formula(formula: str | None, op_name: str) -> str: + """Require a non-null formula string.""" + if formula is None: + raise ValueError(f"{op_name} requires formula.") + return formula + + +def _openpyxl_cell_value(cell: OpenpyxlCellProtocol) -> PatchValue | None: + """Normalize an openpyxl cell value into PatchValue.""" + value = getattr(cell, "value", None) + if value is None: + return None + data_type = getattr(cell, "data_type", None) + if data_type == "f": + text = _normalize_formula(value) + return PatchValue(kind="formula", value=text) + return PatchValue(kind="value", value=value) + + +def _normalize_formula(value: object) -> str: + """Ensure formula string starts with '='.""" + text = str(value) + return text if text.startswith("=") else f"={text}" + + +def _expand_range_coordinates(range_ref: str) -> list[list[str]]: + """Expand A1 range string into a 2D list of coordinates.""" + try: + from openpyxl.utils.cell import get_column_letter, range_boundaries + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + min_col, min_row, max_col, max_row = range_boundaries(range_ref) + if min_col > max_col or min_row > max_row: + raise ValueError(f"Invalid range reference: {range_ref}") + rows: list[list[str]] = [] + for row_idx in range(min_row, max_row + 1): + row: list[str] = [] + for col_idx in range(min_col, max_col + 1): + row.append(f"{get_column_letter(col_idx)}{row_idx}") + rows.append(row) + return rows + + +def _shape_of_coordinates(coordinates: list[list[str]]) -> tuple[int, int]: + """Return rows/cols for expanded coordinates.""" + if not coordinates or not coordinates[0]: + raise ValueError("Range expansion resulted in an empty coordinate set.") + return len(coordinates), len(coordinates[0]) + + +def _expand_rect_coordinates(base_cell: str, rows: int, cols: int) -> list[str]: + """Expand base cell + size into a flat coordinate list.""" + base_column, base_row = _split_a1(base_cell) + start_col = _column_label_to_index(base_column) + coordinates: list[str] = [] + for row_offset in range(rows): + for col_offset in range(cols): + column = _column_index_to_label(start_col + col_offset) + coordinates.append(f"{column}{base_row + row_offset}") + return coordinates + + +def _resolve_style_targets(op: PatchOp) -> list[str]: + """Resolve style operation target coordinates.""" + if op.cell is not None: + return [op.cell] + if op.range is None: + raise ValueError(f"{op.op} requires cell or range.") + coordinates = _expand_range_coordinates(op.range) + targets: list[str] = [] + for row in coordinates: + targets.extend(row) + return targets + + +def _merged_range_strings(sheet: OpenpyxlWorksheetProtocol) -> list[str]: + """Return normalized merged range strings from worksheet.""" + merged_cells = getattr(sheet, "merged_cells", None) + ranges = getattr(merged_cells, "ranges", None) + if ranges is None: + return [] + return [str(item) for item in ranges] + + +def _intersecting_merged_ranges( + sheet: OpenpyxlWorksheetProtocol, scope_range: str +) -> list[str]: + """Return merged ranges that intersect the scope.""" + intersections: list[str] = [] + for merged_range in _merged_range_strings(sheet): + if _ranges_overlap(scope_range, merged_range): + intersections.append(merged_range) + return intersections + + +def _ranges_overlap(left: str, right: str) -> bool: + """Return True if two A1 ranges overlap.""" + left_min_col, left_min_row, left_max_col, left_max_row = _range_bounds(left) + right_min_col, right_min_row, right_max_col, right_max_row = _range_bounds(right) + return not ( + left_max_col < right_min_col + or right_max_col < left_min_col + or left_max_row < right_min_row + or right_max_row < left_min_row + ) + + +def _range_bounds(range_ref: str) -> tuple[int, int, int, int]: + """Return range boundaries in (min_col, min_row, max_col, max_row).""" + try: + from openpyxl.utils.cell import range_boundaries + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + return cast(tuple[int, int, int, int], range_boundaries(range_ref)) + + +def _build_merge_value_loss_warning( + sheet: OpenpyxlWorksheetProtocol, + sheet_name: str, + range_ref: str, +) -> str | None: + """Build warning when merge can clear non-top-left cell values.""" + coordinates = _expand_range_coordinates(range_ref) + top_left = coordinates[0][0] + risky_cells: list[str] = [] + for row in coordinates: + for coord in row: + if coord == top_left: + continue + value = sheet[coord].value + if _has_non_empty_cell_value(value): + risky_cells.append(coord) + if not risky_cells: + return None + joined = ", ".join(risky_cells) + return ( + f"merge_cells may clear non-top-left values at {sheet_name}!{range_ref}: " + f"{joined}" + ) + + +def _has_non_empty_cell_value(value: str | int | float | None) -> bool: + """Return True when cell has a non-empty value.""" + if value is None: + return False + if isinstance(value, str): + return value != "" + return True + + +def _normalize_hex_input(value: str, *, field_name: str) -> str: + """Normalize HEX input into #RRGGBB or #AARRGGBB form. + + Args: + value: Raw user input value. + field_name: Field name used in validation messages. + + Returns: + Normalized uppercase HEX string with '#'. + + Raises: + ValueError: If the value is not valid HEX color text. + """ + text = value.strip().upper() + if not _HEX_COLOR_PATTERN.match(text): + raise ValueError( + f"Invalid {field_name} format. Use 'RRGGBB', 'AARRGGBB', " + "'#RRGGBB', or '#AARRGGBB'." + ) + return text if text.startswith("#") else f"#{text}" + + +def _normalize_hex_color(value: str) -> str: + """Normalize HEX input into AARRGGBB form for workbook internals.""" + normalized = _normalize_hex_input(value, field_name="color/fill_color") + raw = normalized[1:] + return raw if len(raw) == 8 else f"FF{raw}" + + +def _normalize_columns_for_dimensions(columns: list[str | int]) -> list[str]: + """Normalize columns list to unique Excel-style labels.""" + normalized: list[str] = [] + seen: set[str] = set() + for raw in columns: + label = ( + _column_index_to_label(raw) if isinstance(raw, int) else raw.strip().upper() + ) + if label in seen: + continue + seen.add(label) + normalized.append(label) + return normalized + + +def _summarize_column_targets(columns: list[str], *, preview_limit: int = 5) -> str: + """Return a concise summary for column target labels.""" + return _summarize_targets(columns, preview_limit=preview_limit) + + +def _summarize_int_targets(values: list[int], *, preview_limit: int = 5) -> str: + """Return a concise summary for numeric target lists.""" + text_values = [str(value) for value in values] + return _summarize_targets(text_values, preview_limit=preview_limit) + + +def _summarize_targets(values: list[str], *, preview_limit: int = 5) -> str: + """Return preview text with total count for diff logs.""" + if not values: + return "(0)" + preview = ", ".join(values[:preview_limit]) + if len(values) > preview_limit: + preview = f"{preview}, ..." + return f"{preview} ({len(values)})" + + +def _clamp_column_width( + width: float, *, min_width: float | None, max_width: float | None +) -> float: + """Clamp a column width by optional lower/upper bounds.""" + clamped = width + if min_width is not None and clamped < min_width: + clamped = min_width + if max_width is not None and clamped > max_width: + clamped = max_width + return float(clamped) + + +def _resolve_auto_fit_columns_openpyxl( + sheet: OpenpyxlWorksheetProtocol, + columns: list[str | int] | None, +) -> list[str]: + """Resolve auto-fit target columns for openpyxl backend.""" + if columns is not None: + return _normalize_columns_for_dimensions(columns) + used_columns = _detect_openpyxl_used_column_indexes(sheet) + if not used_columns: + return ["A"] + return [_column_index_to_label(index) for index in used_columns] + + +def _detect_openpyxl_used_column_indexes( + sheet: OpenpyxlWorksheetProtocol, +) -> list[int]: + """Detect used column indexes from non-empty openpyxl cells.""" + iter_rows = getattr(sheet, "iter_rows", None) + if iter_rows is None: + return [1] + used_indexes: set[int] = set() + for row in iter_rows(): + for cell in row: + if _is_blank_cell_value(getattr(cell, "value", None)): + continue + used_index = _extract_openpyxl_cell_column_index(cell) + if used_index is not None: + used_indexes.add(used_index) + if used_indexes: + return sorted(used_indexes) + max_column = getattr(sheet, "max_column", None) + if isinstance(max_column, int) and max_column > 0: + return list(range(1, max_column + 1)) + return [1] + + +def _collect_openpyxl_target_column_max_lengths( + sheet: OpenpyxlWorksheetProtocol, target_indexes: set[int] +) -> dict[int, int]: + """Collect max display lengths for target columns in a single sheet pass.""" + iter_rows = getattr(sheet, "iter_rows", None) + if iter_rows is None: + return {} + max_lengths: dict[int, int] = {} + for row in iter_rows(): + for cell in row: + column_index = _extract_openpyxl_cell_column_index(cell) + if column_index is None or column_index not in target_indexes: + continue + cell_value = getattr(cell, "value", None) + if _is_blank_cell_value(cell_value): + continue + text_len = _text_display_length(cell_value) + prev = max_lengths.get(column_index, 0) + if text_len > prev: + max_lengths[column_index] = text_len + return max_lengths + + +def _resolve_openpyxl_estimated_width( + column_dimension: OpenpyxlColumnDimensionProtocol, max_len: int +) -> float: + """Resolve estimated width from max text length or current default width.""" + if max_len <= 0: + default_width = getattr(column_dimension, "width", None) + if isinstance(default_width, int | float) and default_width > 0: + return float(default_width) + return 8.43 + return float(max_len + 2) + + +def _extract_openpyxl_cell_column_index(cell: object) -> int | None: + """Extract 1-based column index from an openpyxl cell-like object.""" + raw_column = getattr(cell, "column", None) + if isinstance(raw_column, int): + return raw_column if raw_column > 0 else None + if isinstance(raw_column, str): + normalized = raw_column.strip().upper() + if not normalized: + return None + return _column_label_to_index(normalized) + coordinate = str(getattr(cell, "coordinate", "")).strip() + if not coordinate: + return None + if not _A1_PATTERN.match(coordinate): + return None + column_label, _ = _split_a1(coordinate) + return _column_label_to_index(column_label) + + +def _is_blank_cell_value(value: object) -> bool: + """Return True when the value is considered blank for width detection.""" + if value is None: + return True + return isinstance(value, str) and value == "" + + +def _text_display_length(value: object) -> int: + """Estimate visible text length for one cell value.""" + text = str(value) + lines = text.splitlines() or [text] + return max(len(line) for line in lines) + + +def _set_grid_border(cell: OpenpyxlCellProtocol) -> None: + """Set thin black border on all sides.""" + try: + from openpyxl.styles import Side + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + + side = Side(style="thin", color="FF000000") + border = copy(cell.border) + border.top = side + border.right = side + border.bottom = side + border.left = side + cell.border = border + + +def _snapshot_border(cell: OpenpyxlCellProtocol, coordinate: str) -> BorderSnapshot: + """Capture border snapshot for one cell.""" + border = cell.border + return BorderSnapshot( + cell=coordinate, + top=_snapshot_border_side(border.top), + right=_snapshot_border_side(border.right), + bottom=_snapshot_border_side(border.bottom), + left=_snapshot_border_side(border.left), + ) + + +def _snapshot_border_side(side: object) -> BorderSideSnapshot: + """Capture one border side state.""" + style = getattr(side, "style", None) + color = _extract_openpyxl_color(getattr(side, "color", None)) + return BorderSideSnapshot(style=style, color=color) + + +def _snapshot_font(cell: OpenpyxlCellProtocol, coordinate: str) -> FontSnapshot: + """Capture font snapshot for one cell.""" + font = cell.font + return FontSnapshot( + cell=coordinate, + bold=getattr(font, "bold", None), + size=getattr(font, "size", None), + color=_extract_openpyxl_color(getattr(font, "color", None)), + ) + + +def _snapshot_fill(cell: OpenpyxlCellProtocol, coordinate: str) -> FillSnapshot: + """Capture fill snapshot for one cell.""" + fill = cell.fill + return FillSnapshot( + cell=coordinate, + fill_type=getattr(fill, "fill_type", None), + start_color=_extract_openpyxl_color(getattr(fill, "start_color", None)), + end_color=_extract_openpyxl_color(getattr(fill, "end_color", None)), + ) + + +def _snapshot_alignment( + cell: OpenpyxlCellProtocol, coordinate: str +) -> AlignmentSnapshot: + """Capture alignment snapshot for one cell.""" + alignment = cell.alignment + return AlignmentSnapshot( + cell=coordinate, + horizontal=getattr(alignment, "horizontal", None), + vertical=getattr(alignment, "vertical", None), + wrap_text=getattr(alignment, "wrap_text", None), + ) + + +def _extract_openpyxl_color(color: object) -> str | None: + """Extract RGB-like color text from openpyxl color object.""" + rgb = getattr(color, "rgb", None) + if rgb is None: + return None + text = str(rgb).upper() + return text if len(text) == 8 else None + + +def _build_restore_snapshot_op(sheet: str, snapshot: DesignSnapshot) -> PatchOp | None: + """Build a restore op when snapshot contains data.""" + if ( + not snapshot.borders + and not snapshot.fonts + and not snapshot.fills + and not snapshot.alignments + and snapshot.merge_state is None + and not snapshot.row_dimensions + and not snapshot.column_dimensions + ): + return None + return PatchOp(op="restore_design_snapshot", sheet=sheet, design_snapshot=snapshot) + + +def _restore_design_snapshot( + sheet: OpenpyxlWorksheetProtocol, + snapshot: DesignSnapshot, +) -> None: + """Restore cell style and dimension snapshot.""" + if snapshot.merge_state is not None: + _restore_merge_state(sheet, snapshot.merge_state) + for border_snapshot in snapshot.borders: + _restore_border(sheet[border_snapshot.cell], border_snapshot) + for font_snapshot in snapshot.fonts: + cell = sheet[font_snapshot.cell] + font = copy(cell.font) + font.bold = font_snapshot.bold + font.size = font_snapshot.size + font.color = font_snapshot.color + cell.font = font + for fill_snapshot in snapshot.fills: + _restore_fill(sheet[fill_snapshot.cell], fill_snapshot) + for alignment_snapshot in snapshot.alignments: + _restore_alignment(sheet[alignment_snapshot.cell], alignment_snapshot) + for row_snapshot in snapshot.row_dimensions: + sheet.row_dimensions[row_snapshot.row].height = row_snapshot.height + for column_snapshot in snapshot.column_dimensions: + sheet.column_dimensions[column_snapshot.column].width = column_snapshot.width + + +def _restore_merge_state( + sheet: OpenpyxlWorksheetProtocol, + snapshot: MergeStateSnapshot, +) -> None: + """Restore merged ranges for a scope deterministically.""" + for range_ref in _intersecting_merged_ranges(sheet, snapshot.scope): + sheet.unmerge_cells(range_ref) + for range_ref in snapshot.ranges: + sheet.merge_cells(range_ref) + + +def _restore_border(cell: OpenpyxlCellProtocol, snapshot: BorderSnapshot) -> None: + """Restore border from snapshot.""" + border = copy(cell.border) + border.top = _build_side_from_snapshot(snapshot.top) + border.right = _build_side_from_snapshot(snapshot.right) + border.bottom = _build_side_from_snapshot(snapshot.bottom) + border.left = _build_side_from_snapshot(snapshot.left) + cell.border = border + + +def _build_side_from_snapshot(snapshot: BorderSideSnapshot) -> OpenpyxlSideProtocol: + """Build openpyxl Side object from serializable snapshot.""" + try: + from openpyxl.styles import Side + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + + kwargs: dict[str, str] = {} + if snapshot.style is not None: + kwargs["style"] = snapshot.style + if snapshot.color is not None: + kwargs["color"] = snapshot.color + return cast(OpenpyxlSideProtocol, Side(**kwargs)) + + +def _restore_fill(cell: OpenpyxlCellProtocol, snapshot: FillSnapshot) -> None: + """Restore fill from snapshot.""" + try: + from openpyxl.styles import PatternFill + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + + cell.fill = PatternFill( + fill_type=snapshot.fill_type, + start_color=snapshot.start_color, + end_color=snapshot.end_color, + ) + + +def _restore_alignment(cell: OpenpyxlCellProtocol, snapshot: AlignmentSnapshot) -> None: + """Restore alignment from snapshot.""" + alignment = copy(cell.alignment) + alignment.horizontal = snapshot.horizontal + alignment.vertical = snapshot.vertical + alignment.wrap_text = snapshot.wrap_text + cell.alignment = alignment + + +def _translate_formula(formula: str, origin: str, target: str) -> str: + """Translate formula with relative references from origin to target.""" + try: + from openpyxl.formula.translate import Translator + except ImportError as exc: + raise RuntimeError(f"openpyxl is not available: {exc}") from exc + translated = Translator(formula, origin=origin).translate_formula(target) + return str(translated) + + +def _patch_value_to_primitive(value: PatchValue | None) -> str | int | float | None: + """Convert PatchValue into primitive value for condition checks.""" + if value is None: + return None + return value.value + + +def _values_equal_for_condition( + current: str | int | float | None, + expected: str | int | float | None, +) -> bool: + """Compare values for conditional update checks.""" + return current == expected + + +def _build_inverse_cell_op( + op: PatchOp, + cell_ref: str, + before: PatchValue | None, +) -> PatchOp | None: + """Build inverse operation for single-cell updates.""" + if op.op not in {"set_value", "set_formula", "set_value_if", "set_formula_if"}: + return None + if before is None: + return PatchOp(op="set_value", sheet=op.sheet, cell=cell_ref, value=None) + if before.kind == "formula": + return PatchOp( + op="set_formula", + sheet=op.sheet, + cell=cell_ref, + formula=str(before.value), + ) + return PatchOp(op="set_value", sheet=op.sheet, cell=cell_ref, value=before.value) + + +def _collect_formula_issues_openpyxl( + workbook: OpenpyxlWorkbookProtocol, +) -> list[FormulaIssue]: + """Collect simple formula issues by scanning formula text.""" + token_map: dict[str, tuple[FormulaIssueCode, FormulaIssueLevel]] = { + "#REF!": ("ref_error", "error"), + "#NAME?": ("name_error", "error"), + "#DIV/0!": ("div0_error", "error"), + "#VALUE!": ("value_error", "error"), + "#N/A": ("na_error", "warning"), + } + issues: list[FormulaIssue] = [] + for sheet_name in workbook.sheetnames: + sheet = workbook[sheet_name] + iter_rows = getattr(sheet, "iter_rows", None) + if iter_rows is None: + continue + for row in iter_rows(): + for cell in row: + raw = getattr(cell, "value", None) + if not isinstance(raw, str) or not raw.startswith("="): + continue + normalized = raw.upper() + if "==" in normalized: + issues.append( + FormulaIssue( + sheet=sheet_name, + cell=str(getattr(cell, "coordinate", "")), + level="warning", + code="invalid_token", + message="Formula contains duplicated '=' token.", + ) + ) + for token, (code, level) in token_map.items(): + if token in normalized: + issues.append( + FormulaIssue( + sheet=sheet_name, + cell=str(getattr(cell, "coordinate", "")), + level=level, + code=code, + message=f"Formula contains error token {token}.", + ) + ) + return issues + + +def _apply_ops_xlwings( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, +) -> list[PatchDiffItem]: + """Apply operations using Excel COM via xlwings.""" + diff: list[PatchDiffItem] = [] + try: + with _xlwings_workbook(input_path) as workbook: + sheets = {sheet.name: sheet for sheet in workbook.sheets} + for index, op in enumerate(ops): + try: + diff.append( + _apply_xlwings_op(workbook, sheets, op, index, auto_formula) + ) + except ValueError as exc: + raise PatchOpError.from_op(index, op, exc) from exc + workbook.save(str(output_path)) + except ValueError: + raise + except Exception as exc: + raise RuntimeError(f"COM patch failed: {exc}") from exc + return diff + + +def _apply_xlwings_op( + workbook: XlwingsWorkbookProtocol, + sheets: dict[str, XlwingsSheetProtocol], + op: PatchOp, + index: int, + auto_formula: bool, +) -> PatchDiffItem: + """Apply a single op to an xlwings workbook.""" + if op.op == "add_sheet": + if op.sheet in sheets: + raise ValueError(f"Sheet already exists: {op.sheet}") + last = workbook.sheets[-1] if workbook.sheets else None + sheet = workbook.sheets.add(name=op.sheet, after=last) + sheets[op.sheet] = sheet + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="sheet", value=op.sheet), + ) + + existing_sheet = sheets.get(op.sheet) + if existing_sheet is None: + raise ValueError(f"Sheet not found: {op.sheet}") + if op.op in {"set_value", "set_formula", "set_value_if", "set_formula_if"}: + return _apply_xlwings_cell_op(existing_sheet, op, index, auto_formula) + return _apply_xlwings_extended_op(existing_sheet, op, index) + + +def _apply_xlwings_extended_op( + sheet: XlwingsSheetProtocol, + op: PatchOp, + index: int, +) -> PatchDiffItem: + """Apply non-cell operations on xlwings sheets.""" + handlers: dict[PatchOpType, Callable[[], PatchDiffItem]] = { + "set_range_values": lambda: _apply_xlwings_set_range_values(sheet, op, index), + "fill_formula": lambda: _apply_xlwings_fill_formula(sheet, op, index), + "draw_grid_border": lambda: _apply_xlwings_draw_grid_border(sheet, op, index), + "set_bold": lambda: _apply_xlwings_set_bold(sheet, op, index), + "set_font_size": lambda: _apply_xlwings_set_font_size(sheet, op, index), + "set_font_color": lambda: _apply_xlwings_set_font_color(sheet, op, index), + "set_fill_color": lambda: _apply_xlwings_set_fill_color(sheet, op, index), + "set_dimensions": lambda: _apply_xlwings_set_dimensions(sheet, op, index), + "auto_fit_columns": lambda: _apply_xlwings_auto_fit_columns(sheet, op, index), + "merge_cells": lambda: _apply_xlwings_merge_cells(sheet, op, index), + "unmerge_cells": lambda: _apply_xlwings_unmerge_cells(sheet, op, index), + "set_alignment": lambda: _apply_xlwings_set_alignment(sheet, op, index), + "set_style": lambda: _apply_xlwings_set_style(sheet, op, index), + "apply_table_style": lambda: _apply_xlwings_apply_table_style(op), + "restore_design_snapshot": lambda: _apply_xlwings_restore_design_snapshot(op), + } + handler = handlers.get(op.op) + if handler is None: + raise ValueError(f"Unsupported op: {op.op}") + return handler() + + +def _apply_xlwings_set_range_values( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_range_values with xlwings.""" + if op.range is None or op.values is None: + raise ValueError("set_range_values requires range and values.") + coordinates_2d = _expand_range_coordinates(op.range) + row_count, col_count = _shape_of_coordinates(coordinates_2d) + if len(op.values) != row_count: + raise ValueError("set_range_values values height does not match range.") + if any(len(value_row) != col_count for value_row in op.values): + raise ValueError("set_range_values values width does not match range.") + sheet.range(op.range).value = op.values + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="value", value=f"{row_count}x{col_count}"), + ) + + +def _apply_xlwings_fill_formula( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply fill_formula with xlwings.""" + if op.range is None or op.formula is None or op.base_cell is None: + raise ValueError("fill_formula requires range, base_cell and formula.") + coordinates_2d = _expand_range_coordinates(op.range) + row_count, col_count = _shape_of_coordinates(coordinates_2d) + if row_count != 1 and col_count != 1: + raise ValueError("fill_formula range must be a single row or a single column.") + for coord_row in coordinates_2d: + for coord in coord_row: + translated = _translate_formula(op.formula, op.base_cell, coord) + sheet.range(coord).formula = translated + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="formula", value=op.formula), + ) + + +def _apply_xlwings_draw_grid_border( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply draw_grid_border with xlwings.""" + if op.base_cell is None or op.row_count is None or op.col_count is None: + raise ValueError( + "draw_grid_border requires base_cell, row_count and col_count." + ) + coordinates = _expand_rect_coordinates(op.base_cell, op.row_count, op.col_count) + for coord in coordinates: + _set_xlwings_grid_border(sheet.range(coord)) + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=f"{op.base_cell}:{coordinates[-1]}", + before=None, + after=PatchValue(kind="style", value="grid_border(thin,black)"), + ) + + +def _apply_xlwings_set_bold( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_bold with xlwings.""" + target_range_ref = _xlwings_target_range_ref(op) + target_bold = True if op.bold is None else op.bold + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + target_api.Font.Bold = target_bold + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue(kind="style", value=f"bold={target_bold}"), + ) + + +def _apply_xlwings_set_font_size( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_font_size with xlwings.""" + if op.font_size is None: + raise ValueError("set_font_size requires font_size.") + target_range_ref = _xlwings_target_range_ref(op) + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + target_api.Font.Size = op.font_size + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue(kind="style", value=f"font_size={op.font_size}"), + ) + + +def _apply_xlwings_set_font_color( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_font_color with xlwings.""" + if op.color is None: + raise ValueError("set_font_color requires color.") + target_range_ref = _xlwings_target_range_ref(op) + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + normalized = _normalize_hex_input(op.color, field_name="color") + target_api.Font.Color = _hex_color_to_excel_rgb(op.color) + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue(kind="style", value=f"font_color={normalized}"), + ) + + +def _apply_xlwings_set_fill_color( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_fill_color with xlwings.""" + if op.fill_color is None: + raise ValueError("set_fill_color requires fill_color.") + target_range_ref = _xlwings_target_range_ref(op) + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + target_api.Interior.Color = _hex_color_to_excel_rgb(op.fill_color) + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue( + kind="style", + value=f"fill={_normalize_hex_input(op.fill_color, field_name='fill_color')}", + ), + ) + + +def _apply_xlwings_set_dimensions( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_dimensions with xlwings.""" + parts: list[str] = [] + sheet_api = _xlwings_sheet_api(sheet) + if op.rows is not None and op.row_height is not None: + for row_index in op.rows: + sheet_api.Rows(row_index).RowHeight = op.row_height + parts.append(f"rows={_summarize_int_targets(op.rows)}") + if op.columns is not None and op.column_width is not None: + normalized_columns = _normalize_columns_for_dimensions(op.columns) + for column in normalized_columns: + sheet_api.Columns(column).ColumnWidth = op.column_width + parts.append(f"columns={_summarize_column_targets(normalized_columns)}") + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="dimension", value=", ".join(parts)), + ) + + +def _apply_xlwings_auto_fit_columns( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply auto_fit_columns with xlwings COM AutoFit.""" + sheet_api = _xlwings_sheet_api(sheet) + target_columns = _resolve_auto_fit_columns_xlwings(sheet, op.columns) + if not target_columns: + raise ValueError("auto_fit_columns could not resolve target columns.") + for column in target_columns: + column_api = sheet_api.Columns(column) + auto_fit = getattr(column_api, "AutoFit", None) + if callable(auto_fit): + auto_fit() + current_width = getattr(column_api, "ColumnWidth", None) + if isinstance(current_width, int | float): + width_value = float(current_width) + else: + width_value = 8.43 + column_api.ColumnWidth = _clamp_column_width( + width_value, min_width=op.min_width, max_width=op.max_width + ) + parts = [f"columns={_summarize_column_targets(target_columns)}"] + if op.min_width is not None: + parts.append(f"min_width={op.min_width}") + if op.max_width is not None: + parts.append(f"max_width={op.max_width}") + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=None, + before=None, + after=PatchValue(kind="dimension", value=", ".join(parts)), + ) + + +def _apply_xlwings_merge_cells( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply merge_cells with xlwings.""" + if op.range is None: + raise ValueError("merge_cells requires range.") + _xlwings_range_api(sheet.range(op.range)).Merge() + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="style", value=f"merged={op.range}"), + ) + + +def _apply_xlwings_unmerge_cells( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply unmerge_cells with xlwings.""" + if op.range is None: + raise ValueError("unmerge_cells requires range.") + merged_areas = _collect_xlwings_merged_areas(sheet, op.range) + for area in merged_areas: + _xlwings_range_api(sheet.range(area)).UnMerge() + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.range, + before=None, + after=PatchValue(kind="style", value=f"unmerged={len(merged_areas)}"), + ) + + +def _apply_xlwings_set_alignment( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_alignment with xlwings.""" + target_range_ref = _xlwings_target_range_ref(op) + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + if op.horizontal_align is not None: + target_api.HorizontalAlignment = _XLWINGS_HORIZONTAL_ALIGN_MAP[ + op.horizontal_align + ] + if op.vertical_align is not None: + target_api.VerticalAlignment = _XLWINGS_VERTICAL_ALIGN_MAP[op.vertical_align] + if op.wrap_text is not None: + target_api.WrapText = op.wrap_text + summary = ( + f"horizontal={op.horizontal_align}," + f"vertical={op.vertical_align}," + f"wrap_text={op.wrap_text}" + ) + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue(kind="style", value=summary), + ) + + +def _apply_xlwings_set_style( + sheet: XlwingsSheetProtocol, op: PatchOp, index: int +) -> PatchDiffItem: + """Apply set_style with xlwings.""" + target_range_ref = _xlwings_target_range_ref(op) + target_api = _xlwings_range_api(sheet.range(target_range_ref)) + if op.bold is not None: + target_api.Font.Bold = op.bold + if op.font_size is not None: + target_api.Font.Size = op.font_size + if op.color is not None: + target_api.Font.Color = _hex_color_to_excel_rgb(op.color) + if op.fill_color is not None: + target_api.Interior.Color = _hex_color_to_excel_rgb(op.fill_color) + if op.horizontal_align is not None: + target_api.HorizontalAlignment = _XLWINGS_HORIZONTAL_ALIGN_MAP[ + op.horizontal_align + ] + if op.vertical_align is not None: + target_api.VerticalAlignment = _XLWINGS_VERTICAL_ALIGN_MAP[op.vertical_align] + if op.wrap_text is not None: + target_api.WrapText = op.wrap_text + return PatchDiffItem( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=target_range_ref, + before=None, + after=PatchValue( + kind="style", value=";".join(_build_set_style_summary_parts(op)) + ), + ) + + +def _apply_xlwings_apply_table_style(op: PatchOp) -> PatchDiffItem: + """Reject apply_table_style on COM backend.""" + raise ValueError("apply_table_style is supported only on openpyxl backend.") + + +def _apply_xlwings_restore_design_snapshot(op: PatchOp) -> PatchDiffItem: + """Reject restore_design_snapshot on COM backend.""" + raise ValueError("restore_design_snapshot is supported only on openpyxl backend.") + + +def _apply_xlwings_cell_op( + sheet: XlwingsSheetProtocol, + op: PatchOp, + index: int, + auto_formula: bool, +) -> PatchDiffItem: + """Apply single-cell operations on xlwings sheets.""" + cell_ref = op.cell + if cell_ref is None: + raise ValueError(f"{op.op} requires cell.") + rng = sheet.range(cell_ref) + before = _xlwings_cell_value(rng) + if op.op == "set_value": + after = _set_xlwings_cell_value( + rng, op.value, auto_formula, op_name="set_value" + ) + return _build_cell_result(op, index, cell_ref, before, after) + if op.op == "set_formula": + formula = _require_formula(op.formula, "set_formula") + rng.formula = formula + return _build_cell_result( + op, + index, + cell_ref, + before, + PatchValue(kind="formula", value=formula), + ) + if op.op == "set_value_if": + if not _values_equal_for_condition( + _patch_value_to_primitive(before), op.expected + ): + return _build_skipped_result(op, index, cell_ref, before) + after = _set_xlwings_cell_value( + rng, + op.value, + auto_formula, + op_name="set_value_if", + ) + return _build_cell_result(op, index, cell_ref, before, after) + formula_if = _require_formula(op.formula, "set_formula_if") + if not _values_equal_for_condition(_patch_value_to_primitive(before), op.expected): + return _build_skipped_result(op, index, cell_ref, before) + rng.formula = formula_if + return _build_cell_result( + op, + index, + cell_ref, + before, + PatchValue(kind="formula", value=formula_if), + ) + + +def _set_xlwings_cell_value( + cell: XlwingsRangeProtocol, + value: str | int | float | None, + auto_formula: bool, + *, + op_name: str, +) -> PatchValue: + """Set xlwings cell value with auto_formula handling.""" + if isinstance(value, str) and value.startswith("="): + if not auto_formula: + raise ValueError(f"{op_name} rejects values starting with '='.") + cell.formula = value + return PatchValue(kind="formula", value=value) + cell.value = value + return PatchValue(kind="value", value=value) + + +def _resolve_auto_fit_columns_xlwings( + sheet: XlwingsSheetProtocol, columns: list[str | int] | None +) -> list[str]: + """Resolve auto-fit target columns for xlwings backend.""" + if columns is not None: + return _normalize_columns_for_dimensions(columns) + used_range = getattr(sheet, "used_range", None) + if used_range is None: + return ["A"] + last_cell = getattr(used_range, "last_cell", None) + last_column = getattr(last_cell, "column", None) + if isinstance(last_column, int) and last_column > 0: + return [_column_index_to_label(index) for index in range(1, last_column + 1)] + return ["A"] + + +def _xlwings_range_api(target: XlwingsRangeProtocol) -> XlwingsRangeApiProtocol: + """Return COM range API object from xlwings wrapper.""" + return cast(XlwingsRangeApiProtocol, target.api) + + +def _xlwings_sheet_api(target: XlwingsSheetProtocol) -> XlwingsSheetApiProtocol: + """Return COM sheet API object from xlwings wrapper.""" + return cast(XlwingsSheetApiProtocol, target.api) + + +def _xlwings_target_range_ref(op: PatchOp) -> str: + """Return target range reference from a style operation payload.""" + if op.cell is not None: + return op.cell + if op.range is not None: + return op.range + raise ValueError(f"{op.op} requires cell or range.") + + +def _set_xlwings_grid_border(cell: XlwingsRangeProtocol) -> None: + """Set thin black border on all four sides via Excel COM.""" + cell_api = _xlwings_range_api(cell) + for edge in (7, 8, 9, 10): + border = cell_api.Borders(edge) + border.LineStyle = 1 + border.Color = 0 + + +def _hex_color_to_excel_rgb(fill_color: str) -> int: + """Convert hex color to Excel COM RGB integer.""" + argb = _normalize_hex_color(fill_color) + rgb = argb[2:] + red = int(rgb[0:2], 16) + green = int(rgb[2:4], 16) + blue = int(rgb[4:6], 16) + return red + green * 256 + blue * 65_536 + + +def _collect_xlwings_merged_areas( + sheet: XlwingsSheetProtocol, + target_range: str, +) -> list[str]: + """Collect unique merged range addresses intersecting target range.""" + merged_areas: set[str] = set() + for coord_row in _expand_range_coordinates(target_range): + for coord in coord_row: + cell_api = _xlwings_range_api(sheet.range(coord)) + if not bool(cell_api.MergeCells): + continue + merge_area = cell_api.MergeArea + raw_address = str(merge_area.Address(False, False)) + merged_areas.add(raw_address.replace("$", "")) + return sorted(merged_areas) + + +def _xlwings_cell_value(cell: XlwingsRangeProtocol) -> PatchValue | None: + """Normalize an xlwings cell value into PatchValue.""" + formula = getattr(cell, "formula", None) + if isinstance(formula, str) and formula.startswith("="): + return PatchValue(kind="formula", value=formula) + value = getattr(cell, "value", None) + if value is None: + return None + return PatchValue(kind="value", value=value) + + +def _close_workbook_safely(workbook: XlwingsWorkbookProtocol) -> None: + """Close workbook and ignore cleanup failures.""" + try: + workbook.close() + except Exception: + return + + +def _quit_app_safely(app: XlwingsAppProtocol) -> None: + """Quit xlwings app and fallback to force-kill on failure.""" + try: + app.quit() + except Exception: + try: + app.kill() + except Exception: + return + + +@contextmanager +def _xlwings_workbook(file_path: Path) -> Iterator[XlwingsWorkbookProtocol]: + """Open an Excel workbook with a dedicated COM app.""" + app = xw.App(add_book=False, visible=False) + app.display_alerts = False + app.screen_updating = False + workbook = app.books.open(str(file_path)) + try: + yield workbook + finally: + _close_workbook_safely(workbook) + _quit_app_safely(app) + + +class PatchOpError(ValueError): + """Patch operation error with structured detail.""" + + def __init__(self, detail: PatchErrorDetail) -> None: + super().__init__(detail.message) + self.detail = detail + + @classmethod + def from_op(cls, index: int, op: PatchOp, exc: Exception) -> PatchOpError: + """Build a PatchOpError from an op and exception.""" + hint, expected_fields, example_op = _build_patch_error_guidance(op, str(exc)) + detail = PatchErrorDetail( + op_index=index, + op=op.op, + sheet=op.sheet, + cell=op.cell, + message=str(exc), + hint=hint, + expected_fields=expected_fields, + example_op=example_op, + ) + return cls(detail) + + +def _build_patch_error_guidance( + op: PatchOp, message: str +) -> tuple[str | None, list[str], str | None]: + """Build structured guidance for common operation mistakes.""" + if op.op == "set_fill_color" and ( + "does not accept color" in message or "requires fill_color" in message + ): + return ( + "set_fill_color では 'color' ではなく 'fill_color' を指定してください。", + ["op", "sheet", "cell or range", "fill_color"], + ( + '{"op":"set_fill_color","sheet":"Sheet1",' + '"cell":"A1","fill_color":"#FFD966"}' + ), + ) + if op.op == "set_alignment" and "requires at least one of" in message: + return ( + "set_alignment は horizontal_align / vertical_align / wrap_text の" + " いずれかが必須です。alias の 'horizontal' / 'vertical' も利用できます。", + [ + "op", + "sheet", + "cell or range", + "horizontal_align/vertical_align/wrap_text", + ], + ( + '{"op":"set_alignment","sheet":"Sheet1","range":"A1:B1",' + '"horizontal_align":"center"}' + ), + ) + if op.op == "set_style" and "requires at least one style field" in message: + return ( + "set_style では style 属性を少なくとも1つ指定してください。", + [ + "op", + "sheet", + "cell or range", + "bold/font_size/color/fill_color/horizontal_align/vertical_align/wrap_text", + ], + ( + '{"op":"set_style","sheet":"Sheet1","range":"A1:B1",' + '"bold":true,"fill_color":"#D9E1F2","horizontal_align":"center"}' + ), + ) + return None, [], None diff --git a/src/exstruct/mcp/patch/models.py b/src/exstruct/mcp/patch/models.py new file mode 100644 index 0000000..af02800 --- /dev/null +++ b/src/exstruct/mcp/patch/models.py @@ -0,0 +1,1407 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from pathlib import Path +import re +from typing import Protocol, runtime_checkable + +from pydantic import BaseModel, Field, field_validator, model_validator + +from ..extract_runner import OnConflictPolicy +from ..shared.a1 import ( + column_index_to_label as _shared_column_index_to_label, + column_label_to_index as _shared_column_label_to_index, + range_cell_count as _shared_range_cell_count, + split_a1 as _shared_split_a1, +) +from .types import ( + FormulaIssueCode, + FormulaIssueLevel, + HorizontalAlignType, + PatchBackend, + PatchEngine, + PatchOpType, + PatchStatus, + PatchValueKind, + VerticalAlignType, +) + +_A1_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*$") +_A1_RANGE_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*:[A-Za-z]{1,3}[1-9][0-9]*$") +_HEX_COLOR_PATTERN = re.compile(r"^#?(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") +_COLUMN_LABEL_PATTERN = re.compile(r"^[A-Za-z]{1,3}$") +_MAX_STYLE_TARGET_CELLS = 10_000 + + +class BorderSideSnapshot(BaseModel): + """Serializable border side state for inverse restoration.""" + + style: str | None = None + color: str | None = None + + +class BorderSnapshot(BaseModel): + """Serializable border state for one cell.""" + + cell: str + top: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + right: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + bottom: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + left: BorderSideSnapshot = Field(default_factory=BorderSideSnapshot) + + +class FontSnapshot(BaseModel): + """Serializable font state for one cell.""" + + cell: str + bold: bool | None = None + size: float | None = None + color: str | None = None + + +class FillSnapshot(BaseModel): + """Serializable fill state for one cell.""" + + cell: str + fill_type: str | None = None + start_color: str | None = None + end_color: str | None = None + + +class AlignmentSnapshot(BaseModel): + """Serializable alignment state for one cell.""" + + cell: str + horizontal: str | None = None + vertical: str | None = None + wrap_text: bool | None = None + + +class MergeStateSnapshot(BaseModel): + """Serializable merged-range state for deterministic restoration.""" + + scope: str + ranges: list[str] = Field(default_factory=list) + + +class RowDimensionSnapshot(BaseModel): + """Serializable row height state.""" + + row: int + height: float | None = None + + +class ColumnDimensionSnapshot(BaseModel): + """Serializable column width state.""" + + column: str + width: float | None = None + + +class DesignSnapshot(BaseModel): + """Serializable style/dimension snapshot for inverse restore.""" + + borders: list[BorderSnapshot] = Field(default_factory=list) + fonts: list[FontSnapshot] = Field(default_factory=list) + fills: list[FillSnapshot] = Field(default_factory=list) + alignments: list[AlignmentSnapshot] = Field(default_factory=list) + merge_state: MergeStateSnapshot | None = None + row_dimensions: list[RowDimensionSnapshot] = Field(default_factory=list) + column_dimensions: list[ColumnDimensionSnapshot] = Field(default_factory=list) + + +@runtime_checkable +class OpenpyxlCellProtocol(Protocol): + """Protocol for openpyxl cell access used by patch runner.""" + + value: str | int | float | None + data_type: str | None + font: OpenpyxlFontProtocol + fill: OpenpyxlFillProtocol + border: OpenpyxlBorderProtocol + alignment: OpenpyxlAlignmentProtocol + + +@runtime_checkable +class OpenpyxlColorProtocol(Protocol): + """Protocol for openpyxl color access.""" + + rgb: object | None + + +@runtime_checkable +class OpenpyxlSideProtocol(Protocol): + """Protocol for openpyxl border side access.""" + + style: str | None + color: OpenpyxlColorProtocol | None + + +@runtime_checkable +class OpenpyxlBorderProtocol(Protocol): + """Protocol for openpyxl border access.""" + + top: OpenpyxlSideProtocol + right: OpenpyxlSideProtocol + bottom: OpenpyxlSideProtocol + left: OpenpyxlSideProtocol + + +@runtime_checkable +class OpenpyxlFontProtocol(Protocol): + """Protocol for openpyxl font access.""" + + bold: bool | None + size: float | None + color: object | None + + +@runtime_checkable +class OpenpyxlFillProtocol(Protocol): + """Protocol for openpyxl fill access.""" + + fill_type: str | None + start_color: OpenpyxlColorProtocol | None + end_color: OpenpyxlColorProtocol | None + + +@runtime_checkable +class OpenpyxlAlignmentProtocol(Protocol): + """Protocol for openpyxl alignment access.""" + + horizontal: str | None + vertical: str | None + wrap_text: bool | None + + +@runtime_checkable +class OpenpyxlRowDimensionProtocol(Protocol): + """Protocol for openpyxl row dimension access.""" + + height: float | None + + +@runtime_checkable +class OpenpyxlColumnDimensionProtocol(Protocol): + """Protocol for openpyxl column dimension access.""" + + width: float | None + + +@runtime_checkable +class OpenpyxlRowDimensionsProtocol(Protocol): + """Protocol for openpyxl row dimensions collection.""" + + def __getitem__(self, key: int) -> OpenpyxlRowDimensionProtocol: ... + + +@runtime_checkable +class OpenpyxlColumnDimensionsProtocol(Protocol): + """Protocol for openpyxl column dimensions collection.""" + + def __getitem__(self, key: str) -> OpenpyxlColumnDimensionProtocol: ... + + +@runtime_checkable +class OpenpyxlWorksheetProtocol(Protocol): + """Protocol for openpyxl worksheet access used by patch runner.""" + + row_dimensions: OpenpyxlRowDimensionsProtocol + column_dimensions: OpenpyxlColumnDimensionsProtocol + + def __getitem__(self, key: str) -> OpenpyxlCellProtocol: ... + + def merge_cells(self, range_string: str) -> None: ... + + def unmerge_cells(self, range_string: str) -> None: ... + + +@runtime_checkable +class OpenpyxlTablesProtocol(Protocol): + """Protocol for openpyxl worksheet tables collection.""" + + def items(self) -> Iterator[tuple[object, object]]: ... + + +@runtime_checkable +class OpenpyxlWorkbookProtocol(Protocol): + """Protocol for openpyxl workbook access used by patch runner.""" + + sheetnames: list[str] + + def __getitem__(self, key: str) -> OpenpyxlWorksheetProtocol: ... + + def create_sheet(self, title: str) -> OpenpyxlWorksheetProtocol: ... + + def save(self, filename: str | Path) -> None: ... + + def close(self) -> None: ... + + +@runtime_checkable +class XlwingsRangeProtocol(Protocol): + """Protocol for xlwings range access used by patch runner.""" + + value: object | None + formula: str | None + api: object + + +@runtime_checkable +class XlwingsSheetProtocol(Protocol): + """Protocol for xlwings sheet access used by patch runner.""" + + name: str + api: object + + def range(self, cell: str) -> XlwingsRangeProtocol: ... + + +@runtime_checkable +class XlwingsSheetsProtocol(Protocol): + """Protocol for xlwings sheets collection.""" + + def __iter__(self) -> Iterator[XlwingsSheetProtocol]: ... + + def __len__(self) -> int: ... + + def __getitem__(self, index: int) -> XlwingsSheetProtocol: ... + + def add( + self, name: str, after: XlwingsSheetProtocol | None = None + ) -> XlwingsSheetProtocol: ... + + +@runtime_checkable +class XlwingsWorkbookProtocol(Protocol): + """Protocol for xlwings workbook access used by patch runner.""" + + sheets: XlwingsSheetsProtocol + + def save(self, filename: str) -> None: ... + + def close(self) -> None: ... + + +@runtime_checkable +class XlwingsFontApiProtocol(Protocol): + """Protocol for xlwings COM font API.""" + + Bold: bool + Size: float + Color: int + + +@runtime_checkable +class XlwingsInteriorApiProtocol(Protocol): + """Protocol for xlwings COM interior API.""" + + Color: int + + +@runtime_checkable +class XlwingsBorderApiProtocol(Protocol): + """Protocol for xlwings COM border API.""" + + LineStyle: int + Color: int + + +@runtime_checkable +class XlwingsMergeAreaApiProtocol(Protocol): + """Protocol for xlwings COM merged-area API.""" + + def Address(self, row_absolute: bool, column_absolute: bool) -> str: ... # noqa: N802 + + +@runtime_checkable +class XlwingsRangeApiProtocol(Protocol): + """Protocol for xlwings COM range API.""" + + Font: XlwingsFontApiProtocol + Interior: XlwingsInteriorApiProtocol + MergeCells: bool + MergeArea: XlwingsMergeAreaApiProtocol + HorizontalAlignment: int + VerticalAlignment: int + WrapText: bool + + def Borders(self, edge: int) -> XlwingsBorderApiProtocol: ... # noqa: N802 + + def Merge(self) -> None: ... # noqa: N802 + + def UnMerge(self) -> None: ... # noqa: N802 + + +@runtime_checkable +class XlwingsRowApiProtocol(Protocol): + """Protocol for xlwings COM row API.""" + + RowHeight: float + + +@runtime_checkable +class XlwingsColumnApiProtocol(Protocol): + """Protocol for xlwings COM column API.""" + + ColumnWidth: float + + def AutoFit(self) -> None: ... # noqa: N802 + + +@runtime_checkable +class XlwingsSheetApiProtocol(Protocol): + """Protocol for xlwings COM sheet API.""" + + def Rows(self, index: int) -> XlwingsRowApiProtocol: ... # noqa: N802 + + def Columns(self, key: str) -> XlwingsColumnApiProtocol: ... # noqa: N802 + + +class PatchOp(BaseModel): + """Single patch operation for an Excel workbook. + + Operation types and their required fields: + + - ``set_value``: Set a cell value. Requires ``sheet``, ``cell``, ``value``. + - ``set_formula``: Set a cell formula. Requires ``sheet``, ``cell``, ``formula`` (must start with ``=``). + - ``add_sheet``: Add a new worksheet. Requires ``sheet`` (new sheet name). No ``cell``/``value``/``formula``. + - ``set_range_values``: Set values for a rectangular range. Requires ``sheet``, ``range`` (e.g. ``A1:C3``), ``values`` (2D list matching range shape). + - ``fill_formula``: Fill a formula across a single row or column. Requires ``sheet``, ``range``, ``base_cell``, ``formula``. + - ``set_value_if``: Conditionally set value. Requires ``sheet``, ``cell``, ``value``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. + - ``set_formula_if``: Conditionally set formula. Requires ``sheet``, ``cell``, ``formula``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. + - ``draw_grid_border``: Draw thin black borders on a target rectangle. + - ``set_bold``: Set bold style for one cell or one range. + - ``set_font_size``: Set font size for one cell or one range. + - ``set_font_color``: Set font color for one cell or one range. + - ``set_fill_color``: Set solid fill color for one cell or one range. + - ``set_dimensions``: Set row height and/or column width. + - ``auto_fit_columns``: Auto-fit column widths with optional bounds. + - ``merge_cells``: Merge a rectangular range. + - ``unmerge_cells``: Unmerge all merged ranges intersecting target range. + - ``set_alignment``: Set horizontal/vertical alignment and/or wrap_text. + - ``set_style``: Set multiple style attributes in one operation. + - ``apply_table_style``: Create an Excel table and apply table style. + - ``restore_design_snapshot``: Restore style/dimension snapshot (internal inverse op). + """ + + op: PatchOpType = Field( + description=( + "Operation type: 'set_value', 'set_formula', 'add_sheet', " + "'set_range_values', 'fill_formula', 'set_value_if', 'set_formula_if', " + "'draw_grid_border', 'set_bold', 'set_font_size', 'set_font_color', " + "'set_fill_color', " + "'set_dimensions', " + "'auto_fit_columns', " + "'merge_cells', 'unmerge_cells', 'set_alignment', 'set_style', " + "'apply_table_style', " + "or 'restore_design_snapshot'." + ) + ) + sheet: str = Field( + description="Target sheet name. For add_sheet, this is the new sheet name." + ) + cell: str | None = Field( + default=None, + description="Cell reference in A1 notation (e.g. 'B2'). Required for set_value, set_formula, set_value_if, set_formula_if.", + ) + range: str | None = Field( + default=None, + description="Range reference in A1 notation (e.g. 'A1:C3'). Required for set_range_values and fill_formula.", + ) + base_cell: str | None = Field( + default=None, + description="Base cell for formula translation in fill_formula (e.g. 'C2').", + ) + expected: str | int | float | None = Field( + default=None, + description="Expected current value for conditional ops (set_value_if, set_formula_if). Operation is skipped if mismatch.", + ) + value: str | int | float | None = Field( + default=None, + description="Value to set. Use null to clear a cell. For set_value and set_value_if.", + ) + values: list[list[str | int | float | None]] | None = Field( + default=None, + description="2D list of values for set_range_values. Shape must match the range dimensions.", + ) + formula: str | None = Field( + default=None, + description="Formula string starting with '=' (e.g. '=SUM(A1:A10)'). For set_formula, set_formula_if, fill_formula.", + ) + row_count: int | None = Field( + default=None, + description="Row count for draw_grid_border.", + ) + col_count: int | None = Field( + default=None, + description="Column count for draw_grid_border.", + ) + bold: bool | None = Field( + default=None, + description="Bold flag for set_bold. Defaults to true.", + ) + font_size: float | None = Field( + default=None, + description="Font size for set_font_size. Must be > 0.", + ) + color: str | None = Field( + default=None, + description="Font color for set_font_color in RRGGBB/AARRGGBB (with optional '#').", + ) + fill_color: str | None = Field( + default=None, + description="Fill color for set_fill_color in RRGGBB/AARRGGBB (with optional '#').", + ) + rows: list[int] | None = Field( + default=None, + description="Row indexes for set_dimensions.", + ) + columns: list[str | int] | None = Field( + default=None, + description="Column identifiers for set_dimensions. Accepts letters (A/AA) or positive indexes.", + ) + row_height: float | None = Field( + default=None, + description="Target row height for set_dimensions.", + ) + column_width: float | None = Field( + default=None, + description="Target column width for set_dimensions.", + ) + min_width: float | None = Field( + default=None, + description="Optional minimum width bound for auto_fit_columns.", + ) + max_width: float | None = Field( + default=None, + description="Optional maximum width bound for auto_fit_columns.", + ) + horizontal_align: HorizontalAlignType | None = Field( + default=None, + description="Horizontal alignment for set_alignment/set_style.", + ) + vertical_align: VerticalAlignType | None = Field( + default=None, + description="Vertical alignment for set_alignment/set_style.", + ) + wrap_text: bool | None = Field( + default=None, + description="Wrap text flag for set_alignment/set_style.", + ) + style: str | None = Field( + default=None, + description="Table style name for apply_table_style.", + ) + table_name: str | None = Field( + default=None, + description="Optional table name for apply_table_style.", + ) + design_snapshot: DesignSnapshot | None = Field( + default=None, + description="Design snapshot payload for restore_design_snapshot.", + ) + + @field_validator("sheet") + @classmethod + def _validate_sheet(cls, value: str) -> str: + if not value.strip(): + raise ValueError("sheet must not be empty.") + return value + + @field_validator("cell") + @classmethod + def _validate_cell(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_PATTERN.match(candidate): + raise ValueError(f"Invalid cell reference: {value}") + return candidate.upper() + + @field_validator("base_cell") + @classmethod + def _validate_base_cell(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_PATTERN.match(candidate): + raise ValueError(f"Invalid base_cell reference: {value}") + return candidate.upper() + + @field_validator("range") + @classmethod + def _validate_range(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not _A1_RANGE_PATTERN.match(candidate): + raise ValueError(f"Invalid range reference: {value}") + start, end = candidate.split(":", maxsplit=1) + return f"{start.upper()}:{end.upper()}" + + @field_validator("fill_color") + @classmethod + def _validate_fill_color(cls, value: str | None) -> str | None: + if value is None: + return None + return _normalize_hex_input(value, field_name="fill_color") + + @field_validator("color") + @classmethod + def _validate_color(cls, value: str | None) -> str | None: + if value is None: + return None + return _normalize_hex_input(value, field_name="color") + + @field_validator("rows") + @classmethod + def _validate_rows(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return None + if not value: + raise ValueError("rows must not be empty.") + normalized: list[int] = [] + for row in value: + if row < 1: + raise ValueError("rows must contain positive integers.") + normalized.append(row) + return normalized + + @field_validator("columns") + @classmethod + def _validate_columns(cls, value: list[str | int] | None) -> list[str | int] | None: + if value is None: + return None + if not value: + raise ValueError("columns must not be empty.") + normalized: list[str | int] = [] + for column in value: + normalized.append(_normalize_column_identifier(column)) + return normalized + + @field_validator("style", "table_name") + @classmethod + def _validate_non_empty_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not candidate: + raise ValueError("style/table_name must not be empty when provided.") + return candidate + + @field_validator("min_width", "max_width") + @classmethod + def _validate_optional_positive_width(cls, value: float | None) -> float | None: + if value is None: + return None + if value <= 0: + raise ValueError("min_width/max_width must be > 0.") + return value + + @model_validator(mode="after") + def _validate_op(self) -> PatchOp: + validator = _validator_for_op(self.op) + if validator is None: + return self + if self.op in _CELL_REQUIRED_OPS: + _validate_cell_required(self) + validator(self) + return self + + +_CELL_REQUIRED_OPS: set[PatchOpType] = { + "set_value", + "set_formula", + "set_value_if", + "set_formula_if", +} + + +def _validator_for_op(op_type: PatchOpType) -> Callable[[PatchOp], None] | None: + """Return per-op validator function.""" + validators: dict[PatchOpType, Callable[[PatchOp], None]] = { + "add_sheet": _validate_add_sheet, + "set_value": _validate_set_value, + "set_formula": _validate_set_formula, + "set_range_values": _validate_set_range_values, + "fill_formula": _validate_fill_formula, + "set_value_if": _validate_set_value_if, + "set_formula_if": _validate_set_formula_if, + "draw_grid_border": _validate_draw_grid_border, + "set_bold": _validate_set_bold, + "set_font_size": _validate_set_font_size, + "set_font_color": _validate_set_font_color, + "set_fill_color": _validate_set_fill_color, + "set_dimensions": _validate_set_dimensions, + "auto_fit_columns": _validate_auto_fit_columns, + "merge_cells": _validate_merge_cells, + "unmerge_cells": _validate_unmerge_cells, + "set_alignment": _validate_set_alignment, + "set_style": _validate_set_style, + "apply_table_style": _validate_apply_table_style, + "restore_design_snapshot": _validate_restore_design_snapshot, + } + return validators.get(op_type) + + +def _validate_add_sheet(op: PatchOp) -> None: + """Validate add_sheet operation.""" + _validate_no_design_fields(op, op_name="add_sheet") + if op.cell is not None: + raise ValueError("add_sheet does not accept cell.") + if op.range is not None: + raise ValueError("add_sheet does not accept range.") + if op.base_cell is not None: + raise ValueError("add_sheet does not accept base_cell.") + if op.expected is not None: + raise ValueError("add_sheet does not accept expected.") + if op.value is not None: + raise ValueError("add_sheet does not accept value.") + if op.values is not None: + raise ValueError("add_sheet does not accept values.") + if op.formula is not None: + raise ValueError("add_sheet does not accept formula.") + + +def _validate_cell_required(op: PatchOp) -> None: + """Validate that the operation has a cell value.""" + if op.cell is None: + raise ValueError(f"{op.op} requires cell.") + + +def _validate_set_value(op: PatchOp) -> None: + """Validate set_value operation.""" + _validate_no_design_fields(op, op_name="set_value") + if op.range is not None: + raise ValueError("set_value does not accept range.") + if op.base_cell is not None: + raise ValueError("set_value does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_value does not accept expected.") + if op.values is not None: + raise ValueError("set_value does not accept values.") + if op.formula is not None: + raise ValueError("set_value does not accept formula.") + + +def _validate_set_formula(op: PatchOp) -> None: + """Validate set_formula operation.""" + _validate_no_design_fields(op, op_name="set_formula") + if op.range is not None: + raise ValueError("set_formula does not accept range.") + if op.base_cell is not None: + raise ValueError("set_formula does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_formula does not accept expected.") + if op.values is not None: + raise ValueError("set_formula does not accept values.") + if op.value is not None: + raise ValueError("set_formula does not accept value.") + if op.formula is None: + raise ValueError("set_formula requires formula.") + if not op.formula.startswith("="): + raise ValueError("set_formula requires formula starting with '='.") + + +def _validate_set_range_values(op: PatchOp) -> None: + """Validate set_range_values operation.""" + _validate_no_design_fields(op, op_name="set_range_values") + if op.cell is not None: + raise ValueError("set_range_values does not accept cell.") + if op.base_cell is not None: + raise ValueError("set_range_values does not accept base_cell.") + if op.expected is not None: + raise ValueError("set_range_values does not accept expected.") + if op.formula is not None: + raise ValueError("set_range_values does not accept formula.") + if op.range is None: + raise ValueError("set_range_values requires range.") + if op.values is None: + raise ValueError("set_range_values requires values.") + if not op.values: + raise ValueError("set_range_values requires non-empty values.") + if not all(op.values): + raise ValueError("set_range_values values rows must not be empty.") + expected_width = len(op.values[0]) + if any(len(row) != expected_width for row in op.values): + raise ValueError("set_range_values requires rectangular values.") + + +def _validate_fill_formula(op: PatchOp) -> None: + """Validate fill_formula operation.""" + _validate_no_design_fields(op, op_name="fill_formula") + if op.cell is not None: + raise ValueError("fill_formula does not accept cell.") + if op.expected is not None: + raise ValueError("fill_formula does not accept expected.") + if op.value is not None: + raise ValueError("fill_formula does not accept value.") + if op.values is not None: + raise ValueError("fill_formula does not accept values.") + if op.range is None: + raise ValueError("fill_formula requires range.") + if op.base_cell is None: + raise ValueError("fill_formula requires base_cell.") + if op.formula is None: + raise ValueError("fill_formula requires formula.") + if not op.formula.startswith("="): + raise ValueError("fill_formula requires formula starting with '='.") + + +def _validate_set_value_if(op: PatchOp) -> None: + """Validate set_value_if operation.""" + _validate_no_design_fields(op, op_name="set_value_if") + if op.formula is not None: + raise ValueError("set_value_if does not accept formula.") + if op.range is not None: + raise ValueError("set_value_if does not accept range.") + if op.values is not None: + raise ValueError("set_value_if does not accept values.") + if op.base_cell is not None: + raise ValueError("set_value_if does not accept base_cell.") + + +def _validate_set_formula_if(op: PatchOp) -> None: + """Validate set_formula_if operation.""" + _validate_no_design_fields(op, op_name="set_formula_if") + if op.value is not None: + raise ValueError("set_formula_if does not accept value.") + if op.range is not None: + raise ValueError("set_formula_if does not accept range.") + if op.values is not None: + raise ValueError("set_formula_if does not accept values.") + if op.base_cell is not None: + raise ValueError("set_formula_if does not accept base_cell.") + if op.formula is None: + raise ValueError("set_formula_if requires formula.") + if not op.formula.startswith("="): + raise ValueError("set_formula_if requires formula starting with '='.") + + +def _validate_draw_grid_border(op: PatchOp) -> None: + """Validate draw_grid_border operation.""" + _validate_no_legacy_edit_fields(op, op_name="draw_grid_border") + if op.cell is not None or op.range is not None: + raise ValueError("draw_grid_border does not accept cell or range.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("draw_grid_border does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("draw_grid_border does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("draw_grid_border does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("draw_grid_border does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("draw_grid_border does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="draw_grid_border") + if op.base_cell is None: + raise ValueError("draw_grid_border requires base_cell.") + if op.row_count is None or op.col_count is None: + raise ValueError("draw_grid_border requires row_count and col_count.") + if op.row_count < 1 or op.col_count < 1: + raise ValueError("draw_grid_border requires row_count >= 1 and col_count >= 1.") + if op.row_count * op.col_count > _MAX_STYLE_TARGET_CELLS: + raise ValueError( + f"draw_grid_border target exceeds max cells: {_MAX_STYLE_TARGET_CELLS}." + ) + + +def _validate_set_bold(op: PatchOp) -> None: + """Validate set_bold operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_bold") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_bold does not accept row_count or col_count.") + if op.color is not None or op.fill_color is not None: + raise ValueError("set_bold does not accept color or fill_color.") + if op.font_size is not None: + raise ValueError("set_bold does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_bold does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_bold does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_bold does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_bold") + _validate_exactly_one_cell_or_range(op, op_name="set_bold") + if op.bold is None: + op.bold = True + _validate_style_target_size(op, op_name="set_bold") + + +def _validate_set_font_size(op: PatchOp) -> None: + """Validate set_font_size operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_font_size") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_font_size does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_font_size does not accept bold, color, or fill_color.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_font_size does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_font_size does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_font_size does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_font_size") + _validate_exactly_one_cell_or_range(op, op_name="set_font_size") + if op.font_size is None: + raise ValueError("set_font_size requires font_size.") + if op.font_size <= 0: + raise ValueError("set_font_size font_size must be > 0.") + _validate_style_target_size(op, op_name="set_font_size") + + +def _validate_set_font_color(op: PatchOp) -> None: + """Validate set_font_color operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_font_color") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_font_color does not accept row_count or col_count.") + if op.bold is not None: + raise ValueError("set_font_color does not accept bold.") + if op.font_size is not None: + raise ValueError("set_font_color does not accept font_size.") + if op.fill_color is not None: + raise ValueError("set_font_color does not accept fill_color.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_font_color does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_font_color does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_font_color does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_font_color") + _validate_exactly_one_cell_or_range(op, op_name="set_font_color") + if op.color is None: + raise ValueError("set_font_color requires color.") + _validate_style_target_size(op, op_name="set_font_color") + + +def _validate_set_fill_color(op: PatchOp) -> None: + """Validate set_fill_color operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_fill_color") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_fill_color does not accept row_count or col_count.") + if op.bold is not None: + raise ValueError("set_fill_color does not accept bold.") + if op.color is not None: + raise ValueError("set_fill_color does not accept color.") + if op.font_size is not None: + raise ValueError("set_fill_color does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_fill_color does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_fill_color does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_fill_color does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_fill_color") + _validate_exactly_one_cell_or_range(op, op_name="set_fill_color") + if op.fill_color is None: + raise ValueError("set_fill_color requires fill_color.") + _validate_style_target_size(op, op_name="set_fill_color") + + +def _validate_set_dimensions(op: PatchOp) -> None: + """Validate set_dimensions operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_dimensions") + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError("set_dimensions does not accept cell/range/base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_dimensions does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_dimensions does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("set_dimensions does not accept font_size.") + if op.design_snapshot is not None: + raise ValueError("set_dimensions does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="set_dimensions") + has_rows = op.rows is not None + has_columns = op.columns is not None + if not has_rows and not has_columns: + raise ValueError("set_dimensions requires rows and/or columns.") + if has_rows and op.row_height is None: + raise ValueError("set_dimensions requires row_height when rows is provided.") + if has_columns and op.column_width is None: + raise ValueError( + "set_dimensions requires column_width when columns is provided." + ) + if op.row_height is not None and op.row_height <= 0: + raise ValueError("set_dimensions row_height must be > 0.") + if op.column_width is not None and op.column_width <= 0: + raise ValueError("set_dimensions column_width must be > 0.") + + +def _validate_auto_fit_columns(op: PatchOp) -> None: + """Validate auto_fit_columns operation.""" + _validate_no_legacy_edit_fields( + op, op_name="auto_fit_columns", allow_auto_fit_fields=True + ) + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError("auto_fit_columns does not accept cell/range/base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("auto_fit_columns does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("auto_fit_columns does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("auto_fit_columns does not accept font_size.") + if op.rows is not None or op.row_height is not None or op.column_width is not None: + raise ValueError( + "auto_fit_columns does not accept rows, row_height, or column_width." + ) + if op.design_snapshot is not None: + raise ValueError("auto_fit_columns does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="auto_fit_columns") + if ( + op.min_width is not None + and op.max_width is not None + and op.min_width > op.max_width + ): + raise ValueError("auto_fit_columns requires min_width <= max_width.") + + +def _validate_merge_cells(op: PatchOp) -> None: + """Validate merge_cells operation.""" + _validate_no_legacy_edit_fields(op, op_name="merge_cells") + if op.cell is not None or op.base_cell is not None: + raise ValueError("merge_cells does not accept cell or base_cell.") + if op.range is None: + raise ValueError("merge_cells requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("merge_cells does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("merge_cells does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("merge_cells does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("merge_cells does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("merge_cells does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("merge_cells does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="merge_cells") + if _range_cell_count(op.range) < 2: + raise ValueError("merge_cells requires a multi-cell range.") + + +def _validate_unmerge_cells(op: PatchOp) -> None: + """Validate unmerge_cells operation.""" + _validate_no_legacy_edit_fields(op, op_name="unmerge_cells") + if op.cell is not None or op.base_cell is not None: + raise ValueError("unmerge_cells does not accept cell or base_cell.") + if op.range is None: + raise ValueError("unmerge_cells requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("unmerge_cells does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("unmerge_cells does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("unmerge_cells does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("unmerge_cells does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("unmerge_cells does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("unmerge_cells does not accept design_snapshot.") + _validate_no_alignment_fields(op, op_name="unmerge_cells") + + +def _validate_set_alignment(op: PatchOp) -> None: + """Validate set_alignment operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_alignment") + if op.base_cell is not None: + raise ValueError("set_alignment does not accept base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_alignment does not accept row_count or col_count.") + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError("set_alignment does not accept bold, color, or fill_color.") + if op.font_size is not None: + raise ValueError("set_alignment does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_alignment does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_alignment does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_alignment does not accept design_snapshot.") + _validate_exactly_one_cell_or_range(op, op_name="set_alignment") + if ( + op.horizontal_align is None + and op.vertical_align is None + and op.wrap_text is None + ): + raise ValueError( + "set_alignment requires at least one of horizontal_align, vertical_align, or wrap_text." + ) + _validate_style_target_size(op, op_name="set_alignment") + + +def _validate_set_style(op: PatchOp) -> None: + """Validate set_style operation.""" + _validate_no_legacy_edit_fields(op, op_name="set_style") + if op.base_cell is not None: + raise ValueError("set_style does not accept base_cell.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("set_style does not accept row_count or col_count.") + if op.rows is not None or op.columns is not None: + raise ValueError("set_style does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError("set_style does not accept row_height or column_width.") + if op.design_snapshot is not None: + raise ValueError("set_style does not accept design_snapshot.") + _validate_exactly_one_cell_or_range(op, op_name="set_style") + if ( + op.bold is None + and op.font_size is None + and op.color is None + and op.fill_color is None + and op.horizontal_align is None + and op.vertical_align is None + and op.wrap_text is None + ): + raise ValueError( + "set_style requires at least one style field from: " + "bold, font_size, color, fill_color, horizontal_align, vertical_align, wrap_text." + ) + if op.font_size is not None and op.font_size <= 0: + raise ValueError("set_style font_size must be > 0.") + _validate_style_target_size(op, op_name="set_style") + + +def _validate_apply_table_style(op: PatchOp) -> None: + """Validate apply_table_style operation.""" + _validate_no_legacy_edit_fields( + op, op_name="apply_table_style", allow_table_fields=True + ) + if op.cell is not None or op.base_cell is not None: + raise ValueError("apply_table_style does not accept cell or base_cell.") + if op.range is None: + raise ValueError("apply_table_style requires range.") + if op.row_count is not None or op.col_count is not None: + raise ValueError("apply_table_style does not accept row_count or col_count.") + if ( + op.bold is not None + or op.color is not None + or op.fill_color is not None + or op.font_size is not None + ): + raise ValueError( + "apply_table_style does not accept bold, color, fill_color, or font_size." + ) + if op.rows is not None or op.columns is not None: + raise ValueError("apply_table_style does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError( + "apply_table_style does not accept row_height or column_width." + ) + _validate_no_alignment_fields(op, op_name="apply_table_style") + if op.design_snapshot is not None: + raise ValueError("apply_table_style does not accept design_snapshot.") + if op.style is None: + raise ValueError("apply_table_style requires style.") + + +def _validate_restore_design_snapshot(op: PatchOp) -> None: + """Validate restore_design_snapshot operation.""" + _validate_no_legacy_edit_fields(op, op_name="restore_design_snapshot") + if op.cell is not None or op.range is not None or op.base_cell is not None: + raise ValueError( + "restore_design_snapshot does not accept cell/range/base_cell." + ) + if op.row_count is not None or op.col_count is not None: + raise ValueError( + "restore_design_snapshot does not accept row_count or col_count." + ) + if op.bold is not None or op.color is not None or op.fill_color is not None: + raise ValueError( + "restore_design_snapshot does not accept bold, color, or fill_color." + ) + if op.font_size is not None: + raise ValueError("restore_design_snapshot does not accept font_size.") + if op.rows is not None or op.columns is not None: + raise ValueError("restore_design_snapshot does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError( + "restore_design_snapshot does not accept row_height or column_width." + ) + _validate_no_alignment_fields(op, op_name="restore_design_snapshot") + if op.design_snapshot is None: + raise ValueError("restore_design_snapshot requires design_snapshot.") + + +def _validate_no_legacy_edit_fields( + op: PatchOp, + *, + op_name: str, + allow_table_fields: bool = False, + allow_auto_fit_fields: bool = False, +) -> None: + """Reject fields that are unrelated to design operations.""" + if op.expected is not None: + raise ValueError(f"{op_name} does not accept expected.") + if op.value is not None: + raise ValueError(f"{op_name} does not accept value.") + if op.values is not None: + raise ValueError(f"{op_name} does not accept values.") + if op.formula is not None: + raise ValueError(f"{op_name} does not accept formula.") + if not allow_table_fields: + if op.style is not None: + raise ValueError(f"{op_name} does not accept style.") + if op.table_name is not None: + raise ValueError(f"{op_name} does not accept table_name.") + if not allow_auto_fit_fields: + if op.min_width is not None: + raise ValueError(f"{op_name} does not accept min_width.") + if op.max_width is not None: + raise ValueError(f"{op_name} does not accept max_width.") + + +def _validate_no_design_fields(op: PatchOp, *, op_name: str) -> None: + """Reject design-only fields for legacy value edit operations.""" + if op.row_count is not None or op.col_count is not None: + raise ValueError(f"{op_name} does not accept row_count or col_count.") + if op.rows is not None or op.columns is not None: + raise ValueError(f"{op_name} does not accept rows or columns.") + if op.row_height is not None or op.column_width is not None: + raise ValueError(f"{op_name} does not accept row_height or column_width.") + _reject_optional_field(op_name, "bold", op.bold) + _reject_optional_field(op_name, "color", op.color) + _reject_optional_field(op_name, "font_size", op.font_size) + _reject_optional_field(op_name, "fill_color", op.fill_color) + _reject_optional_field(op_name, "style", op.style) + _reject_optional_field(op_name, "table_name", op.table_name) + _validate_no_alignment_fields(op, op_name=op_name) + _reject_optional_field(op_name, "design_snapshot", op.design_snapshot) + _reject_optional_field(op_name, "min_width", op.min_width) + _reject_optional_field(op_name, "max_width", op.max_width) + + +def _reject_optional_field(op_name: str, field_name: str, value: object) -> None: + """Raise when an optional field is provided for an unsupported op.""" + if value is not None: + raise ValueError(f"{op_name} does not accept {field_name}.") + + +def _validate_no_alignment_fields(op: PatchOp, *, op_name: str) -> None: + """Reject alignment-only fields for unrelated operations.""" + if op.horizontal_align is not None: + raise ValueError(f"{op_name} does not accept horizontal_align.") + if op.vertical_align is not None: + raise ValueError(f"{op_name} does not accept vertical_align.") + if op.wrap_text is not None: + raise ValueError(f"{op_name} does not accept wrap_text.") + + +def _validate_exactly_one_cell_or_range(op: PatchOp, *, op_name: str) -> None: + """Ensure exactly one of cell/range is provided.""" + if op.base_cell is not None: + raise ValueError(f"{op_name} does not accept base_cell.") + has_cell = op.cell is not None + has_range = op.range is not None + if has_cell == has_range: + raise ValueError(f"{op_name} requires exactly one of cell or range.") + + +def _validate_style_target_size(op: PatchOp, *, op_name: str) -> None: + """Guard style edits against accidental huge targets.""" + target_count = 1 if op.cell is not None else _range_cell_count(op.range) + if target_count > _MAX_STYLE_TARGET_CELLS: + raise ValueError( + f"{op_name} target exceeds max cells: {_MAX_STYLE_TARGET_CELLS}." + ) + + +def _range_cell_count(range_ref: str | None) -> int: + """Return the number of cells represented by an A1 range.""" + if range_ref is None: + raise ValueError("range is required.") + return _shared_range_cell_count(range_ref) + + +def _split_a1(value: str) -> tuple[str, int]: + """Split A1 notation into normalized (column_label, row_index).""" + return _shared_split_a1(value) + + +def _normalize_column_identifier(value: str | int) -> str | int: + """Normalize a column identifier preserving letter/index semantics.""" + if isinstance(value, int): + if value < 1: + raise ValueError("columns numeric values must be positive.") + return value + label = value.strip().upper() + if not _COLUMN_LABEL_PATTERN.match(label): + raise ValueError(f"Invalid column identifier: {value}") + return label + + +def _column_label_to_index(label: str) -> int: + """Convert Excel-style column label (A/AA) to 1-based index.""" + return _shared_column_label_to_index(label) + + +def _column_index_to_label(index: int) -> str: + """Convert 1-based column index to Excel-style column label.""" + return _shared_column_index_to_label(index) + + +class PatchValue(BaseModel): + """Normalized before/after value in patch diff.""" + + kind: PatchValueKind + value: str | int | float | None + + +class PatchDiffItem(BaseModel): + """Applied change record for patch operations.""" + + op_index: int + op: PatchOpType + sheet: str + cell: str | None = None + before: PatchValue | None = None + after: PatchValue | None = None + status: PatchStatus = "applied" + + +class PatchErrorDetail(BaseModel): + """Structured error details for patch failures.""" + + op_index: int + op: PatchOpType + sheet: str + cell: str | None + message: str + hint: str | None = None + expected_fields: list[str] = Field(default_factory=list) + example_op: str | None = None + + +class FormulaIssue(BaseModel): + """Formula health-check finding.""" + + sheet: str + cell: str + level: FormulaIssueLevel + code: FormulaIssueCode + message: str + + +class PatchRequest(BaseModel): + """Input model for ExStruct MCP patch.""" + + xlsx_path: Path + ops: list[PatchOp] + sheet: str | None = None + out_dir: Path | None = None + out_name: str | None = None + on_conflict: OnConflictPolicy = "overwrite" + auto_formula: bool = False + dry_run: bool = False + return_inverse_ops: bool = False + preflight_formula_check: bool = False + backend: PatchBackend = "auto" + + @model_validator(mode="after") + def _validate_backend_constraints(self) -> PatchRequest: + if self.backend != "com": + return self + if self.dry_run or self.return_inverse_ops or self.preflight_formula_check: + raise ValueError( + "backend='com' does not support dry_run, return_inverse_ops, " + "or preflight_formula_check." + ) + if any(op.op == "restore_design_snapshot" for op in self.ops): + raise ValueError( + "backend='com' does not support restore_design_snapshot operation." + ) + return self + + +class MakeRequest(BaseModel): + """Input model for ExStruct MCP workbook creation.""" + + out_path: Path + ops: list[PatchOp] = Field(default_factory=list) + sheet: str | None = None + on_conflict: OnConflictPolicy = "overwrite" + auto_formula: bool = False + dry_run: bool = False + return_inverse_ops: bool = False + preflight_formula_check: bool = False + backend: PatchBackend = "auto" + + @model_validator(mode="after") + def _validate_backend_constraints(self) -> MakeRequest: + if self.backend != "com": + return self + if self.dry_run or self.return_inverse_ops or self.preflight_formula_check: + raise ValueError( + "backend='com' does not support dry_run, return_inverse_ops, " + "or preflight_formula_check." + ) + if any(op.op == "restore_design_snapshot" for op in self.ops): + raise ValueError( + "backend='com' does not support restore_design_snapshot operation." + ) + return self + + +class OpenpyxlEngineResult(BaseModel): + """Structured result returned by the openpyxl engine boundary. + + Attributes: + patch_diff: Backend patch diff payload items. + inverse_ops: Backend inverse operation payload items. + formula_issues: Backend formula issue payload items. + op_warnings: Backend warning messages emitted during apply. + """ + + patch_diff: list[PatchDiffItem] = Field(default_factory=list) + inverse_ops: list[PatchOp] = Field(default_factory=list) + formula_issues: list[FormulaIssue] = Field(default_factory=list) + op_warnings: list[str] = Field(default_factory=list) + + +class PatchResult(BaseModel): + """Output model for ExStruct MCP patch.""" + + out_path: str + patch_diff: list[PatchDiffItem] = Field(default_factory=list) + inverse_ops: list[PatchOp] = Field(default_factory=list) + formula_issues: list[FormulaIssue] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + error: PatchErrorDetail | None = None + engine: PatchEngine + + +def _normalize_hex_input(value: str, *, field_name: str) -> str: + """Normalize HEX input into #RRGGBB or #AARRGGBB form.""" + text = value.strip().upper() + if not _HEX_COLOR_PATTERN.match(text): + raise ValueError( + f"Invalid {field_name} format. Use 'RRGGBB', 'AARRGGBB', " + "'#RRGGBB', or '#AARRGGBB'." + ) + return text if text.startswith("#") else f"#{text}" + + +__all__ = [ + "AlignmentSnapshot", + "BorderSideSnapshot", + "BorderSnapshot", + "ColumnDimensionSnapshot", + "DesignSnapshot", + "FillSnapshot", + "FontSnapshot", + "FormulaIssue", + "MakeRequest", + "MergeStateSnapshot", + "OpenpyxlWorksheetProtocol", + "OpenpyxlEngineResult", + "PatchDiffItem", + "PatchErrorDetail", + "PatchOp", + "PatchRequest", + "PatchResult", + "PatchValue", + "RowDimensionSnapshot", + "XlwingsRangeProtocol", +] diff --git a/src/exstruct/mcp/patch/normalize.py b/src/exstruct/mcp/patch/normalize.py new file mode 100644 index 0000000..41711ed --- /dev/null +++ b/src/exstruct/mcp/patch/normalize.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +from exstruct.mcp.shared.a1 import parse_range_geometry + +from .specs import get_alias_map_for_op + + +def coerce_patch_ops(ops_data: list[dict[str, Any] | str]) -> list[dict[str, Any]]: + """Normalize patch operations payload for MCP clients.""" + normalized_ops: list[dict[str, Any]] = [] + for index, raw_op in enumerate(ops_data): + parsed_op = ( + dict(raw_op) + if isinstance(raw_op, dict) + else parse_patch_op_json(raw_op, index=index) + ) + normalized_ops.append(normalize_patch_op_aliases(parsed_op, index=index)) + return normalized_ops + + +def resolve_top_level_sheet_for_payload(data: object) -> object: + """Resolve top-level sheet default into operation dict payloads.""" + if not isinstance(data, dict): + return data + ops_raw = data.get("ops") + if not isinstance(ops_raw, list): + return data + top_level_sheet = normalize_top_level_sheet(data.get("sheet")) + resolved_ops: list[object] = [] + for index, op_raw in enumerate(ops_raw): + if not isinstance(op_raw, dict): + resolved_ops.append(op_raw) + continue + op_copy = normalize_patch_op_aliases(dict(op_raw), index=index) + op_name_raw = op_copy.get("op") + op_name = op_name_raw if isinstance(op_name_raw, str) else "" + op_sheet = op_copy.get("sheet") + if op_name == "add_sheet": + if op_copy.get("sheet") is None: + raise ValueError( + build_missing_sheet_message(index=index, op_name="add_sheet") + ) + resolved_ops.append(op_copy) + continue + if op_sheet is None: + if top_level_sheet is None: + raise ValueError( + build_missing_sheet_message(index=index, op_name=op_name) + ) + op_copy["sheet"] = top_level_sheet + resolved_ops.append(op_copy) + payload = dict(data) + payload["ops"] = resolved_ops + if top_level_sheet is not None: + payload["sheet"] = top_level_sheet + return payload + + +def normalize_patch_op_aliases( + op_data: dict[str, Any], *, index: int +) -> dict[str, Any]: + """Normalize MCP-friendly aliases to canonical patch operation fields.""" + normalized = dict(op_data) + op_name = normalized.get("op") + if not isinstance(op_name, str): + return normalized + alias_map = get_alias_map_for_op(op_name) + for alias, canonical in alias_map.items(): + alias_to_canonical_with_conflict_check( + normalized, + index=index, + alias=alias, + canonical=canonical, + op_name=op_name, + ) + normalize_draw_grid_border_range(normalized, index=index) + return normalized + + +def alias_to_canonical_with_conflict_check( + op_data: dict[str, Any], + *, + index: int, + alias: str, + canonical: str, + op_name: str, +) -> None: + """Map alias field to canonical field when operation type matches.""" + if op_data.get("op") != op_name or alias not in op_data: + return + alias_value = op_data[alias] + canonical_value = op_data.get(canonical) + if canonical in op_data: + if canonical_value != alias_value: + raise ValueError( + build_patch_op_error_message( + index, + f"conflicting fields: '{canonical}' and alias '{alias}'", + ) + ) + else: + op_data[canonical] = alias_value + del op_data[alias] + + +def normalize_draw_grid_border_range(op_data: dict[str, Any], *, index: int) -> None: + """Convert draw_grid_border range shorthand to base/size fields.""" + if op_data.get("op") != "draw_grid_border" or "range" not in op_data: + return + if "base_cell" in op_data or "row_count" in op_data or "col_count" in op_data: + raise ValueError( + build_patch_op_error_message( + index, + "draw_grid_border does not allow mixing 'range' with 'base_cell/row_count/col_count'", + ) + ) + range_ref = op_data.get("range") + if not isinstance(range_ref, str): + raise ValueError( + build_patch_op_error_message( + index, "draw_grid_border range must be a string A1 range" + ) + ) + try: + start, row_count, col_count = parse_range_geometry(range_ref) + except ValueError as exc: + raise ValueError( + build_patch_op_error_message( + index, "draw_grid_border range must be like 'A1:C3'" + ) + ) from exc + op_data["base_cell"] = start + op_data["row_count"] = row_count + op_data["col_count"] = col_count + del op_data["range"] + + +def parse_patch_op_json(raw_op: str, *, index: int) -> dict[str, Any]: + """Parse a JSON string patch operation into object form.""" + text = raw_op.strip() + if not text: + raise ValueError(build_patch_op_error_message(index, "empty string")) + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(build_patch_op_error_message(index, "invalid JSON")) from exc + if not isinstance(parsed, dict): + raise ValueError( + build_patch_op_error_message(index, "JSON value must be an object") + ) + return cast(dict[str, Any], parsed) + + +def normalize_top_level_sheet(value: object) -> str | None: + """Normalize optional top-level sheet text.""" + if not isinstance(value, str): + return None + candidate = value.strip() + return candidate or None + + +def build_missing_sheet_message(*, index: int, op_name: str) -> str: + """Build self-healing error for unresolved sheet selection.""" + target_op = op_name or "" + return ( + f"ops[{index}] ({target_op}) is missing sheet. " + "Set op.sheet, or set top-level sheet for non-add_sheet ops. " + "For add_sheet, op.sheet (or alias name) is required." + ) + + +def build_patch_op_error_message(index: int, reason: str) -> str: + """Build a consistent validation message for invalid patch ops.""" + example = '{"op":"set_value","sheet":"Sheet1","cell":"A1","value":"sample"}' + return ( + f"Invalid patch operation at ops[{index}]: {reason}. " + f"Use object form like {example}." + ) diff --git a/src/exstruct/mcp/patch/ops/__init__.py b/src/exstruct/mcp/patch/ops/__init__.py new file mode 100644 index 0000000..61e3cfc --- /dev/null +++ b/src/exstruct/mcp/patch/ops/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .openpyxl_ops import apply_openpyxl_ops +from .xlwings_ops import apply_xlwings_ops + +__all__ = ["apply_openpyxl_ops", "apply_xlwings_ops"] diff --git a/src/exstruct/mcp/patch/ops/common.py b/src/exstruct/mcp/patch/ops/common.py new file mode 100644 index 0000000..2e1017b --- /dev/null +++ b/src/exstruct/mcp/patch/ops/common.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from exstruct.mcp.patch import internal as _internal + +PatchOpError = _internal.PatchOpError + +__all__ = ["PatchOpError"] diff --git a/src/exstruct/mcp/patch/ops/openpyxl_ops.py b/src/exstruct/mcp/patch/ops/openpyxl_ops.py new file mode 100644 index 0000000..5fe874e --- /dev/null +++ b/src/exstruct/mcp/patch/ops/openpyxl_ops.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, TypeVar, cast + +from pydantic import BaseModel, ValidationError + +from exstruct.mcp.patch import internal as _internal +from exstruct.mcp.patch.models import ( + FormulaIssue, + OpenpyxlEngineResult, + PatchDiffItem, + PatchOp, + PatchRequest, +) + +TModel = TypeVar("TModel", bound=BaseModel) + + +def apply_openpyxl_ops( + request: PatchRequest, + input_path: Path, + output_path: Path, +) -> OpenpyxlEngineResult: + """Apply patch operations using the openpyxl implementation.""" + diff, inverse_ops, formula_issues, op_warnings = _internal._apply_ops_openpyxl( + cast(Any, request), + input_path, + output_path, + ) + return OpenpyxlEngineResult( + patch_diff=_coerce_model_list(diff, PatchDiffItem), + inverse_ops=_coerce_model_list(inverse_ops, PatchOp), + formula_issues=_coerce_model_list(formula_issues, FormulaIssue), + op_warnings=list(op_warnings), + ) + + +def _coerce_model_list( + items: Sequence[object], model_cls: type[TModel] +) -> list[TModel]: + """Normalize model-like payloads into canonical Pydantic models. + + Args: + items: Source payload items from internal patch engine. + model_cls: Target Pydantic model class. + + Returns: + Successfully validated models only. + """ + coerced: list[TModel] = [] + for item in items: + try: + source: object + if isinstance(item, model_cls): + coerced.append(item) + continue + if isinstance(item, BaseModel): + source = item.model_dump(mode="python") + else: + source = item + coerced.append(model_cls.model_validate(source)) + except ValidationError: + continue + return coerced + + +__all__ = ["apply_openpyxl_ops"] diff --git a/src/exstruct/mcp/patch/ops/xlwings_ops.py b/src/exstruct/mcp/patch/ops/xlwings_ops.py new file mode 100644 index 0000000..1cb05c2 --- /dev/null +++ b/src/exstruct/mcp/patch/ops/xlwings_ops.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +from exstruct.mcp.patch import internal as _internal +from exstruct.mcp.patch.models import PatchOp + + +def apply_xlwings_ops( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, +) -> list[object]: + """Apply patch operations using the xlwings implementation.""" + diff = _internal._apply_ops_xlwings( + input_path, + output_path, + cast(list[Any], ops), + auto_formula, + ) + return list(diff) + + +__all__ = ["apply_xlwings_ops"] diff --git a/src/exstruct/mcp/patch/runtime.py b/src/exstruct/mcp/patch/runtime.py new file mode 100644 index 0000000..4d7628e --- /dev/null +++ b/src/exstruct/mcp/patch/runtime.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +from exstruct.cli.availability import ComAvailability +from exstruct.mcp.extract_runner import OnConflictPolicy +from exstruct.mcp.io import PathPolicy + +from . import internal as _internal +from .models import MakeRequest, PatchOp, PatchRequest +from .types import PatchEngine + +PatchOpError = _internal.PatchOpError + + +def get_com_availability() -> ComAvailability: + """Return COM availability via the compatibility layer.""" + return _internal.get_com_availability() + + +def append_large_ops_warning(warnings: list[str], ops: list[PatchOp]) -> None: + """Append warnings when patch operation count is large.""" + _internal._append_large_ops_warning(warnings, cast(list[Any], ops)) + + +def contains_apply_table_style_op(ops: list[PatchOp]) -> bool: + """Return whether operations include apply_table_style.""" + return _internal._contains_apply_table_style_op(cast(list[Any], ops)) + + +def contains_design_ops(ops: list[PatchOp]) -> bool: + """Return whether operations include design-affecting ops.""" + return _internal._contains_design_ops(cast(list[Any], ops)) + + +def resolve_make_output_path(path: Path, *, policy: PathPolicy | None) -> Path: + """Resolve output path for make requests.""" + return _internal._resolve_make_output_path(path, policy=policy) + + +def ensure_supported_extension(path: Path) -> None: + """Validate workbook extension for patch/make operations.""" + _internal._ensure_supported_extension(path) + + +def validate_make_request_constraints(request: MakeRequest, output_path: Path) -> None: + """Validate make-request constraints against target output.""" + _internal._validate_make_request_constraints(cast(Any, request), output_path) + + +def build_make_seed_path(output_path: Path) -> Path: + """Return temporary seed workbook path for make operations.""" + return _internal._build_make_seed_path(output_path) + + +def resolve_make_initial_sheet_name(request: MakeRequest) -> str: + """Resolve initial sheet name for make operations.""" + return _internal._resolve_make_initial_sheet_name(cast(Any, request)) + + +def create_seed_workbook( + seed_path: Path, extension: str, *, initial_sheet_name: str +) -> None: + """Create seed workbook used by make operation orchestration.""" + _internal._create_seed_workbook( + seed_path, + extension, + initial_sheet_name=initial_sheet_name, + ) + + +def resolve_input_path(path: Path, *, policy: PathPolicy | None) -> Path: + """Resolve and validate input workbook path.""" + return _internal._resolve_input_path(path, policy=policy) + + +def resolve_output_path( + input_path: Path, + *, + out_dir: Path | None, + out_name: str | None, + policy: PathPolicy | None, +) -> Path: + """Resolve and validate output workbook path.""" + return _internal._resolve_output_path( + input_path, + out_dir=out_dir, + out_name=out_name, + policy=policy, + ) + + +def select_patch_engine( + *, request: PatchRequest, input_path: Path, com_available: bool +) -> PatchEngine: + """Select runtime patch engine based on request and environment.""" + return _internal._select_patch_engine( + request=cast(Any, request), + input_path=input_path, + com_available=com_available, + ) + + +def apply_conflict_policy( + output_path: Path, on_conflict: OnConflictPolicy +) -> tuple[Path, str | None, bool]: + """Apply conflict policy to an output path.""" + return _internal._apply_conflict_policy(output_path, on_conflict) + + +def requires_openpyxl_backend(request: PatchRequest) -> bool: + """Return whether request requires openpyxl backend.""" + return _internal._requires_openpyxl_backend(cast(Any, request)) + + +def ensure_output_dir(path: Path) -> None: + """Ensure parent directory exists for output path.""" + _internal._ensure_output_dir(path) + + +def allow_auto_openpyxl_fallback(request: PatchRequest, input_path: Path) -> bool: + """Return whether COM failures should fallback to openpyxl.""" + return _internal._allow_auto_openpyxl_fallback(cast(Any, request), input_path) + + +def expand_range_coordinates(range_ref: str) -> list[list[str]]: + """Expand an A1 range into 2D cell coordinates.""" + return _internal._expand_range_coordinates(range_ref) + + +__all__ = [ + "PatchOpError", + "allow_auto_openpyxl_fallback", + "append_large_ops_warning", + "apply_conflict_policy", + "build_make_seed_path", + "contains_apply_table_style_op", + "contains_design_ops", + "create_seed_workbook", + "ensure_output_dir", + "ensure_supported_extension", + "expand_range_coordinates", + "get_com_availability", + "requires_openpyxl_backend", + "resolve_input_path", + "resolve_make_initial_sheet_name", + "resolve_make_output_path", + "resolve_output_path", + "select_patch_engine", + "validate_make_request_constraints", + "ComAvailability", +] diff --git a/src/exstruct/mcp/patch/service.py b/src/exstruct/mcp/patch/service.py new file mode 100644 index 0000000..5f039a1 --- /dev/null +++ b/src/exstruct/mcp/patch/service.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import TypeVar + +from pydantic import BaseModel, ValidationError + +from exstruct.mcp.io import PathPolicy + +from . import runtime +from .engine.openpyxl_engine import apply_openpyxl_engine +from .engine.xlwings_engine import apply_xlwings_engine +from .models import ( + FormulaIssue, + MakeRequest, + PatchDiffItem, + PatchErrorDetail, + PatchOp, + PatchRequest, + PatchResult, +) +from .types import PatchOpType + +TModel = TypeVar("TModel", bound=BaseModel) + + +def run_make(request: MakeRequest, *, policy: PathPolicy | None = None) -> PatchResult: + """Create a new workbook and apply patch operations in one call.""" + resolved_output = runtime.resolve_make_output_path(request.out_path, policy=policy) + runtime.ensure_supported_extension(resolved_output) + runtime.validate_make_request_constraints(request, resolved_output) + seed_path = runtime.build_make_seed_path(resolved_output) + initial_sheet_name = runtime.resolve_make_initial_sheet_name(request) + try: + runtime.create_seed_workbook( + seed_path, + resolved_output.suffix.lower(), + initial_sheet_name=initial_sheet_name, + ) + patch_request = PatchRequest( + xlsx_path=seed_path, + ops=request.ops, + sheet=request.sheet, + out_dir=resolved_output.parent, + out_name=resolved_output.name, + on_conflict=request.on_conflict, + auto_formula=request.auto_formula, + dry_run=request.dry_run, + return_inverse_ops=request.return_inverse_ops, + preflight_formula_check=request.preflight_formula_check, + backend=request.backend, + ) + return run_patch(patch_request, policy=policy) + finally: + if seed_path.exists(): + seed_path.unlink() + + +def run_patch( + request: PatchRequest, *, policy: PathPolicy | None = None +) -> PatchResult: + """Run a patch operation and write the updated workbook.""" + resolved_input = runtime.resolve_input_path(request.xlsx_path, policy=policy) + runtime.ensure_supported_extension(resolved_input) + output_path = runtime.resolve_output_path( + resolved_input, + out_dir=request.out_dir, + out_name=request.out_name, + policy=policy, + ) + warnings: list[str] = [] + runtime.append_large_ops_warning(warnings, request.ops) + effective_request = request + if request.backend in {"com", "auto"} and runtime.contains_apply_table_style_op( + request.ops + ): + warnings.append( + "backend='com' does not support apply_table_style; falling back to openpyxl." + ) + effective_request = request.model_copy(update={"backend": "openpyxl"}) + if resolved_input.suffix.lower() == ".xls" and runtime.contains_design_ops( + effective_request.ops + ): + raise ValueError( + "Design operations are not supported for .xls files. Convert to .xlsx/.xlsm first." + ) + com = runtime.get_com_availability() + selected_engine = runtime.select_patch_engine( + request=effective_request, + input_path=resolved_input, + com_available=com.available, + ) + output_path, warning, skipped = runtime.apply_conflict_policy( + output_path, effective_request.on_conflict + ) + if warning: + warnings.append(warning) + if skipped and not effective_request.dry_run: + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=[], + warnings=warnings, + engine=selected_engine, + ) + if skipped and effective_request.dry_run: + warnings.append( + "Dry-run mode ignores on_conflict=skip and simulates patch without writing." + ) + if ( + selected_engine == "openpyxl" + and com.reason + and effective_request.backend == "auto" + ): + warnings.append(f"COM unavailable: {com.reason}") + if selected_engine == "openpyxl" and runtime.requires_openpyxl_backend( + effective_request + ): + warnings.append("Using openpyxl backend due to patch request constraints.") + + runtime.ensure_output_dir(output_path) + if selected_engine == "com": + try: + diff = apply_xlwings_engine( + resolved_input, + output_path, + effective_request.ops, + effective_request.auto_formula, + ) + return PatchResult( + out_path=str(output_path), + patch_diff=_coerce_patch_diff_items(diff), + inverse_ops=[], + formula_issues=[], + warnings=warnings, + engine="com", + ) + except runtime.PatchOpError as exc: + error = _coerce_patch_error_detail(exc.detail) + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=[], + warnings=warnings, + error=error, + engine="com", + ) + except Exception as exc: + if runtime.allow_auto_openpyxl_fallback(effective_request, resolved_input): + warnings.append( + f"COM patch failed; falling back to openpyxl. ({exc!r})" + ) + return _apply_with_openpyxl( + effective_request, + resolved_input, + output_path, + warnings, + ) + raise RuntimeError(f"COM patch failed: {exc}") from exc + + return _apply_with_openpyxl( + effective_request, + resolved_input, + output_path, + warnings, + ) + + +def _apply_with_openpyxl( + request: PatchRequest, + input_path: Path, + output_path: Path, + warnings: list[str], +) -> PatchResult: + """Apply patch operations using openpyxl.""" + try: + engine_result = apply_openpyxl_engine( + request, + input_path, + output_path, + ) + except runtime.PatchOpError as exc: + error = _coerce_patch_error_detail(exc.detail) + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=[], + warnings=warnings, + error=error, + engine="openpyxl", + ) + except ValueError: + raise + except FileNotFoundError: + raise + except OSError: + raise + except Exception as exc: + raise RuntimeError(f"openpyxl patch failed: {exc}") from exc + + patch_diff = _coerce_patch_diff_items(engine_result.patch_diff) + typed_inverse_ops = _coerce_inverse_ops(engine_result.inverse_ops) + typed_formula_issues = _coerce_formula_issues(engine_result.formula_issues) + warnings.extend(engine_result.op_warnings) + if not request.dry_run: + warnings.append( + "openpyxl editing may drop shapes/charts or unsupported elements." + ) + _append_skip_warnings(warnings, patch_diff) + if ( + not request.dry_run + and request.preflight_formula_check + and any(issue.level == "error" for issue in typed_formula_issues) + ): + issue = typed_formula_issues[0] + op_index, op_name = _find_preflight_issue_origin(issue, request.ops) + error = PatchErrorDetail( + op_index=op_index, + op=op_name, + sheet=issue.sheet, + cell=issue.cell, + message=f"Formula health check failed: {issue.message}", + hint=None, + expected_fields=[], + example_op=None, + ) + return PatchResult( + out_path=str(output_path), + patch_diff=[], + inverse_ops=[], + formula_issues=typed_formula_issues, + warnings=warnings, + error=error, + engine="openpyxl", + ) + return PatchResult( + out_path=str(output_path), + patch_diff=patch_diff, + inverse_ops=typed_inverse_ops, + formula_issues=typed_formula_issues, + warnings=warnings, + engine="openpyxl", + ) + + +def _append_skip_warnings(warnings: list[str], diff: list[PatchDiffItem]) -> None: + """Append warning messages for skipped conditional operations.""" + for item in diff: + if item.status != "skipped": + continue + warnings.append( + f"Skipped op[{item.op_index}] {item.op} at {item.sheet}!{item.cell} due to condition mismatch." + ) + + +def _find_preflight_issue_origin( + issue: FormulaIssue, ops: list[PatchOp] +) -> tuple[int, PatchOpType]: + """Find the most likely op index/op name for a preflight formula issue.""" + for index, op in enumerate(ops): + if _op_targets_issue_cell(op, issue.sheet, issue.cell): + return index, op.op + return -1, "set_value" + + +def _op_targets_issue_cell(op: PatchOp, sheet: str, cell: str) -> bool: + """Return True when an op can affect the specified sheet/cell.""" + if op.sheet != sheet: + return False + if op.cell is not None: + return op.cell == cell + if op.range is None: + return False + for row in runtime.expand_range_coordinates(op.range): + if cell in row: + return True + return False + + +def _coerce_patch_diff_items(items: Sequence[object]) -> list[PatchDiffItem]: + """Coerce backend diff items into canonical PatchDiffItem models.""" + return _coerce_model_list(items, PatchDiffItem) + + +def _coerce_inverse_ops(items: Sequence[object]) -> list[PatchOp]: + """Coerce backend inverse ops into canonical PatchOp models.""" + return _coerce_model_list(items, PatchOp) + + +def _coerce_formula_issues(items: Sequence[object]) -> list[FormulaIssue]: + """Coerce backend formula findings into canonical FormulaIssue models.""" + return _coerce_model_list(items, FormulaIssue) + + +def _coerce_patch_error_detail(detail: object) -> PatchErrorDetail | None: + """Coerce backend error detail into canonical PatchErrorDetail model.""" + coerced = _coerce_model_list([detail], PatchErrorDetail) + if not coerced: + return None + return coerced[0] + + +def _coerce_model_list( + items: Sequence[object], model_cls: type[TModel] +) -> list[TModel]: + """Convert model-like items to target Pydantic models and skip invalid entries.""" + coerced: list[TModel] = [] + for item in items: + try: + if isinstance(item, model_cls): + coerced.append(item) + continue + source: object + if isinstance(item, BaseModel): + source = item.model_dump(mode="python") + else: + source = item + coerced.append(model_cls.model_validate(source)) + except ValidationError: + continue + return coerced + + +__all__ = ["run_make", "run_patch"] diff --git a/src/exstruct/mcp/patch/specs.py b/src/exstruct/mcp/patch/specs.py new file mode 100644 index 0000000..62f6be5 --- /dev/null +++ b/src/exstruct/mcp/patch/specs.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Final, cast + +from pydantic import BaseModel, Field + +from .types import PatchOpType + + +class PatchOpSpec(BaseModel): + """Specification metadata used by patch-op normalization.""" + + op: PatchOpType + aliases: dict[str, str] = Field(default_factory=dict) + + +PATCH_OP_SPECS: Final[dict[PatchOpType, PatchOpSpec]] = { + "set_value": PatchOpSpec(op="set_value"), + "set_formula": PatchOpSpec(op="set_formula"), + "add_sheet": PatchOpSpec(op="add_sheet", aliases={"name": "sheet"}), + "set_range_values": PatchOpSpec(op="set_range_values"), + "fill_formula": PatchOpSpec(op="fill_formula"), + "set_value_if": PatchOpSpec(op="set_value_if"), + "set_formula_if": PatchOpSpec(op="set_formula_if"), + "draw_grid_border": PatchOpSpec(op="draw_grid_border"), + "set_bold": PatchOpSpec(op="set_bold"), + "set_font_size": PatchOpSpec(op="set_font_size"), + "set_font_color": PatchOpSpec(op="set_font_color"), + "set_fill_color": PatchOpSpec(op="set_fill_color", aliases={"color": "fill_color"}), + "set_dimensions": PatchOpSpec( + op="set_dimensions", + aliases={ + "row": "rows", + "col": "columns", + "height": "row_height", + "width": "column_width", + }, + ), + "auto_fit_columns": PatchOpSpec(op="auto_fit_columns"), + "merge_cells": PatchOpSpec(op="merge_cells"), + "unmerge_cells": PatchOpSpec(op="unmerge_cells"), + "set_alignment": PatchOpSpec( + op="set_alignment", + aliases={"horizontal": "horizontal_align", "vertical": "vertical_align"}, + ), + "set_style": PatchOpSpec(op="set_style"), + "apply_table_style": PatchOpSpec(op="apply_table_style"), + "restore_design_snapshot": PatchOpSpec(op="restore_design_snapshot"), +} + + +def get_alias_map_for_op(op_name: str) -> dict[str, str]: + """Return alias mapping for one operation name.""" + if op_name not in PATCH_OP_SPECS: + return {} + spec = PATCH_OP_SPECS[cast(PatchOpType, op_name)] + return dict(spec.aliases) diff --git a/src/exstruct/mcp/patch/types.py b/src/exstruct/mcp/patch/types.py new file mode 100644 index 0000000..62ae9ab --- /dev/null +++ b/src/exstruct/mcp/patch/types.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Literal + +PatchOpType = Literal[ + "set_value", + "set_formula", + "add_sheet", + "set_range_values", + "fill_formula", + "set_value_if", + "set_formula_if", + "draw_grid_border", + "set_bold", + "set_font_size", + "set_font_color", + "set_fill_color", + "set_dimensions", + "auto_fit_columns", + "merge_cells", + "unmerge_cells", + "set_alignment", + "set_style", + "apply_table_style", + "restore_design_snapshot", +] +PatchStatus = Literal["applied", "skipped"] +PatchValueKind = Literal["value", "formula", "sheet", "style", "dimension"] +PatchBackend = Literal["auto", "com", "openpyxl"] +PatchEngine = Literal["com", "openpyxl"] +FormulaIssueLevel = Literal["warning", "error"] +FormulaIssueCode = Literal[ + "invalid_token", + "ref_error", + "name_error", + "div0_error", + "value_error", + "na_error", + "circular_ref_suspected", +] + +HorizontalAlignType = Literal[ + "general", + "left", + "center", + "right", + "fill", + "justify", + "centerContinuous", + "distributed", +] +VerticalAlignType = Literal["top", "center", "bottom", "justify", "distributed"] diff --git a/src/exstruct/mcp/patch_runner.py b/src/exstruct/mcp/patch_runner.py index e952373..6506d1c 100644 --- a/src/exstruct/mcp/patch_runner.py +++ b/src/exstruct/mcp/patch_runner.py @@ -1,1315 +1,74 @@ from __future__ import annotations -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -import re -from typing import Literal, Protocol, runtime_checkable - -from pydantic import BaseModel, Field, field_validator, model_validator -import xlwings as xw - -from exstruct.cli.availability import get_com_availability - -from .extract_runner import OnConflictPolicy from .io import PathPolicy - -PatchOpType = Literal[ - "set_value", - "set_formula", - "add_sheet", - "set_range_values", - "fill_formula", - "set_value_if", - "set_formula_if", -] -PatchStatus = Literal["applied", "skipped"] -PatchValueKind = Literal["value", "formula", "sheet"] -FormulaIssueLevel = Literal["warning", "error"] -FormulaIssueCode = Literal[ - "invalid_token", - "ref_error", - "name_error", - "div0_error", - "value_error", - "na_error", - "circular_ref_suspected", -] - -_ALLOWED_EXTENSIONS = {".xlsx", ".xlsm", ".xls"} -_A1_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*$") -_A1_RANGE_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*:[A-Za-z]{1,3}[1-9][0-9]*$") - - -@runtime_checkable -class OpenpyxlCellProtocol(Protocol): - """Protocol for openpyxl cell access used by patch runner.""" - - value: str | int | float | None - data_type: str | None - - -@runtime_checkable -class OpenpyxlWorksheetProtocol(Protocol): - """Protocol for openpyxl worksheet access used by patch runner.""" - - def __getitem__(self, key: str) -> OpenpyxlCellProtocol: ... - - -@runtime_checkable -class OpenpyxlWorkbookProtocol(Protocol): - """Protocol for openpyxl workbook access used by patch runner.""" - - sheetnames: list[str] - - def __getitem__(self, key: str) -> OpenpyxlWorksheetProtocol: ... - - def create_sheet(self, title: str) -> OpenpyxlWorksheetProtocol: ... - - def save(self, filename: str | Path) -> None: ... - - def close(self) -> None: ... - - -@runtime_checkable -class XlwingsRangeProtocol(Protocol): - """Protocol for xlwings range access used by patch runner.""" - - value: str | int | float | None - formula: str | None - - -@runtime_checkable -class XlwingsSheetProtocol(Protocol): - """Protocol for xlwings sheet access used by patch runner.""" - - name: str - - def range(self, cell: str) -> XlwingsRangeProtocol: ... - - -@runtime_checkable -class XlwingsSheetsProtocol(Protocol): - """Protocol for xlwings sheets collection.""" - - def __iter__(self) -> Iterator[XlwingsSheetProtocol]: ... - - def __len__(self) -> int: ... - - def __getitem__(self, index: int) -> XlwingsSheetProtocol: ... - - def add( - self, name: str, after: XlwingsSheetProtocol | None = None - ) -> XlwingsSheetProtocol: ... - - -@runtime_checkable -class XlwingsWorkbookProtocol(Protocol): - """Protocol for xlwings workbook access used by patch runner.""" - - sheets: XlwingsSheetsProtocol - - def save(self, filename: str) -> None: ... - - def close(self) -> None: ... - - -class PatchOp(BaseModel): - """Single patch operation for an Excel workbook. - - Operation types and their required fields: - - - ``set_value``: Set a cell value. Requires ``sheet``, ``cell``, ``value``. - - ``set_formula``: Set a cell formula. Requires ``sheet``, ``cell``, ``formula`` (must start with ``=``). - - ``add_sheet``: Add a new worksheet. Requires ``sheet`` (new sheet name). No ``cell``/``value``/``formula``. - - ``set_range_values``: Set values for a rectangular range. Requires ``sheet``, ``range`` (e.g. ``A1:C3``), ``values`` (2D list matching range shape). - - ``fill_formula``: Fill a formula across a single row or column. Requires ``sheet``, ``range``, ``base_cell``, ``formula``. - - ``set_value_if``: Conditionally set value. Requires ``sheet``, ``cell``, ``value``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. - - ``set_formula_if``: Conditionally set formula. Requires ``sheet``, ``cell``, ``formula``. ``expected`` is optional; ``null`` matches an empty cell. Skips if current value != expected. - """ - - op: PatchOpType = Field( - description=( - "Operation type: 'set_value', 'set_formula', 'add_sheet', " - "'set_range_values', 'fill_formula', 'set_value_if', or 'set_formula_if'." - ) - ) - sheet: str = Field( - description="Target sheet name. For add_sheet, this is the new sheet name." - ) - cell: str | None = Field( - default=None, - description="Cell reference in A1 notation (e.g. 'B2'). Required for set_value, set_formula, set_value_if, set_formula_if.", - ) - range: str | None = Field( - default=None, - description="Range reference in A1 notation (e.g. 'A1:C3'). Required for set_range_values and fill_formula.", - ) - base_cell: str | None = Field( - default=None, - description="Base cell for formula translation in fill_formula (e.g. 'C2').", - ) - expected: str | int | float | None = Field( - default=None, - description="Expected current value for conditional ops (set_value_if, set_formula_if). Operation is skipped if mismatch.", - ) - value: str | int | float | None = Field( - default=None, - description="Value to set. Use null to clear a cell. For set_value and set_value_if.", - ) - values: list[list[str | int | float | None]] | None = Field( - default=None, - description="2D list of values for set_range_values. Shape must match the range dimensions.", - ) - formula: str | None = Field( - default=None, - description="Formula string starting with '=' (e.g. '=SUM(A1:A10)'). For set_formula, set_formula_if, fill_formula.", - ) - - @field_validator("sheet") - @classmethod - def _validate_sheet(cls, value: str) -> str: - if not value.strip(): - raise ValueError("sheet must not be empty.") - return value - - @field_validator("cell") - @classmethod - def _validate_cell(cls, value: str | None) -> str | None: - if value is None: - return None - candidate = value.strip() - if not _A1_PATTERN.match(candidate): - raise ValueError(f"Invalid cell reference: {value}") - return candidate.upper() - - @field_validator("base_cell") - @classmethod - def _validate_base_cell(cls, value: str | None) -> str | None: - if value is None: - return None - candidate = value.strip() - if not _A1_PATTERN.match(candidate): - raise ValueError(f"Invalid base_cell reference: {value}") - return candidate.upper() - - @field_validator("range") - @classmethod - def _validate_range(cls, value: str | None) -> str | None: - if value is None: - return None - candidate = value.strip() - if not _A1_RANGE_PATTERN.match(candidate): - raise ValueError(f"Invalid range reference: {value}") - start, end = candidate.split(":", maxsplit=1) - return f"{start.upper()}:{end.upper()}" - - @model_validator(mode="after") - def _validate_op(self) -> PatchOp: - if self.op == "add_sheet": - _validate_add_sheet(self) - return self - if self.op == "set_value": - _validate_cell_required(self) - _validate_set_value(self) - return self - if self.op == "set_formula": - _validate_cell_required(self) - _validate_set_formula(self) - return self - if self.op == "set_range_values": - _validate_set_range_values(self) - return self - if self.op == "fill_formula": - _validate_fill_formula(self) - return self - if self.op == "set_value_if": - _validate_cell_required(self) - _validate_set_value_if(self) - return self - if self.op == "set_formula_if": - _validate_cell_required(self) - _validate_set_formula_if(self) - return self - return self - - -def _validate_add_sheet(op: PatchOp) -> None: - """Validate add_sheet operation.""" - if op.cell is not None: - raise ValueError("add_sheet does not accept cell.") - if op.range is not None: - raise ValueError("add_sheet does not accept range.") - if op.base_cell is not None: - raise ValueError("add_sheet does not accept base_cell.") - if op.expected is not None: - raise ValueError("add_sheet does not accept expected.") - if op.value is not None: - raise ValueError("add_sheet does not accept value.") - if op.values is not None: - raise ValueError("add_sheet does not accept values.") - if op.formula is not None: - raise ValueError("add_sheet does not accept formula.") - - -def _validate_cell_required(op: PatchOp) -> None: - """Validate that the operation has a cell value.""" - if op.cell is None: - raise ValueError(f"{op.op} requires cell.") - - -def _validate_set_value(op: PatchOp) -> None: - """Validate set_value operation.""" - if op.range is not None: - raise ValueError("set_value does not accept range.") - if op.base_cell is not None: - raise ValueError("set_value does not accept base_cell.") - if op.expected is not None: - raise ValueError("set_value does not accept expected.") - if op.values is not None: - raise ValueError("set_value does not accept values.") - if op.formula is not None: - raise ValueError("set_value does not accept formula.") - - -def _validate_set_formula(op: PatchOp) -> None: - """Validate set_formula operation.""" - if op.range is not None: - raise ValueError("set_formula does not accept range.") - if op.base_cell is not None: - raise ValueError("set_formula does not accept base_cell.") - if op.expected is not None: - raise ValueError("set_formula does not accept expected.") - if op.values is not None: - raise ValueError("set_formula does not accept values.") - if op.value is not None: - raise ValueError("set_formula does not accept value.") - if op.formula is None: - raise ValueError("set_formula requires formula.") - if not op.formula.startswith("="): - raise ValueError("set_formula requires formula starting with '='.") - - -def _validate_set_range_values(op: PatchOp) -> None: - """Validate set_range_values operation.""" - if op.cell is not None: - raise ValueError("set_range_values does not accept cell.") - if op.base_cell is not None: - raise ValueError("set_range_values does not accept base_cell.") - if op.expected is not None: - raise ValueError("set_range_values does not accept expected.") - if op.formula is not None: - raise ValueError("set_range_values does not accept formula.") - if op.range is None: - raise ValueError("set_range_values requires range.") - if op.values is None: - raise ValueError("set_range_values requires values.") - if not op.values: - raise ValueError("set_range_values requires non-empty values.") - if not all(op.values): - raise ValueError("set_range_values values rows must not be empty.") - expected_width = len(op.values[0]) - if any(len(row) != expected_width for row in op.values): - raise ValueError("set_range_values requires rectangular values.") - - -def _validate_fill_formula(op: PatchOp) -> None: - """Validate fill_formula operation.""" - if op.cell is not None: - raise ValueError("fill_formula does not accept cell.") - if op.expected is not None: - raise ValueError("fill_formula does not accept expected.") - if op.value is not None: - raise ValueError("fill_formula does not accept value.") - if op.values is not None: - raise ValueError("fill_formula does not accept values.") - if op.range is None: - raise ValueError("fill_formula requires range.") - if op.base_cell is None: - raise ValueError("fill_formula requires base_cell.") - if op.formula is None: - raise ValueError("fill_formula requires formula.") - if not op.formula.startswith("="): - raise ValueError("fill_formula requires formula starting with '='.") - - -def _validate_set_value_if(op: PatchOp) -> None: - """Validate set_value_if operation.""" - if op.formula is not None: - raise ValueError("set_value_if does not accept formula.") - if op.range is not None: - raise ValueError("set_value_if does not accept range.") - if op.values is not None: - raise ValueError("set_value_if does not accept values.") - if op.base_cell is not None: - raise ValueError("set_value_if does not accept base_cell.") - - -def _validate_set_formula_if(op: PatchOp) -> None: - """Validate set_formula_if operation.""" - if op.value is not None: - raise ValueError("set_formula_if does not accept value.") - if op.range is not None: - raise ValueError("set_formula_if does not accept range.") - if op.values is not None: - raise ValueError("set_formula_if does not accept values.") - if op.base_cell is not None: - raise ValueError("set_formula_if does not accept base_cell.") - if op.formula is None: - raise ValueError("set_formula_if requires formula.") - if not op.formula.startswith("="): - raise ValueError("set_formula_if requires formula starting with '='.") - - -class PatchValue(BaseModel): - """Normalized before/after value in patch diff.""" - - kind: PatchValueKind - value: str | int | float | None - - -class PatchDiffItem(BaseModel): - """Applied change record for patch operations.""" - - op_index: int - op: PatchOpType - sheet: str - cell: str | None = None - before: PatchValue | None = None - after: PatchValue | None = None - status: PatchStatus = "applied" - - -class PatchErrorDetail(BaseModel): - """Structured error details for patch failures.""" - - op_index: int - op: PatchOpType - sheet: str - cell: str | None - message: str - - -class FormulaIssue(BaseModel): - """Formula health-check finding.""" - - sheet: str - cell: str - level: FormulaIssueLevel - code: FormulaIssueCode - message: str - - -class PatchRequest(BaseModel): - """Input model for ExStruct MCP patch.""" - - xlsx_path: Path - ops: list[PatchOp] - out_dir: Path | None = None - out_name: str | None = None - on_conflict: OnConflictPolicy = "overwrite" - auto_formula: bool = False - dry_run: bool = False - return_inverse_ops: bool = False - preflight_formula_check: bool = False - - -class PatchResult(BaseModel): - """Output model for ExStruct MCP patch.""" - - out_path: str - patch_diff: list[PatchDiffItem] = Field(default_factory=list) - inverse_ops: list[PatchOp] = Field(default_factory=list) - formula_issues: list[FormulaIssue] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - error: PatchErrorDetail | None = None +from .patch import internal as _internal, service +from .patch.models import ( + AlignmentSnapshot, + BorderSideSnapshot, + BorderSnapshot, + ColumnDimensionSnapshot, + DesignSnapshot, + FillSnapshot, + FontSnapshot, + FormulaIssue, + MakeRequest, + MergeStateSnapshot, + OpenpyxlWorksheetProtocol, + PatchDiffItem, + PatchErrorDetail, + PatchOp, + PatchRequest, + PatchResult, + PatchValue, + RowDimensionSnapshot, + XlwingsRangeProtocol, +) +from .patch.ops.common import PatchOpError + +get_com_availability = _internal.get_com_availability + + +def _sync_legacy_overrides() -> None: + """Propagate supported monkeypatch overrides to internal module.""" + _internal.get_com_availability = get_com_availability + + +def run_make(request: MakeRequest, *, policy: PathPolicy | None = None) -> PatchResult: + """Compatibility wrapper for make runner.""" + _sync_legacy_overrides() + return service.run_make(request, policy=policy) def run_patch( request: PatchRequest, *, policy: PathPolicy | None = None ) -> PatchResult: - """Run a patch operation and write the updated workbook. - - Args: - request: Patch request payload. - policy: Optional path policy for access control. - - Returns: - Patch result with output path and diff. - - Raises: - FileNotFoundError: If the input file does not exist. - ValueError: If validation fails or the path violates policy. - RuntimeError: If a backend operation fails. - """ - resolved_input = _resolve_input_path(request.xlsx_path, policy=policy) - _ensure_supported_extension(resolved_input) - output_path = _resolve_output_path( - resolved_input, - out_dir=request.out_dir, - out_name=request.out_name, - policy=policy, - ) - output_path, warning, skipped = _apply_conflict_policy( - output_path, request.on_conflict - ) - warnings: list[str] = [] - if warning: - warnings.append(warning) - if skipped and not request.dry_run: - return PatchResult( - out_path=str(output_path), - patch_diff=[], - inverse_ops=[], - formula_issues=[], - warnings=warnings, - ) - if skipped and request.dry_run: - warnings.append( - "Dry-run mode ignores on_conflict=skip and simulates patch without writing." - ) - - com = get_com_availability() - if resolved_input.suffix.lower() == ".xls" and not com.available: - raise ValueError( - ".xls editing requires Windows Excel COM (xlwings) in this environment." - ) - - use_openpyxl = _requires_openpyxl_backend(request) - if use_openpyxl and com.available: - warnings.append("Using openpyxl backend for extended patch features.") - - _ensure_output_dir(output_path) - if com.available and not use_openpyxl: - try: - diff = _apply_ops_xlwings( - resolved_input, - output_path, - request.ops, - request.auto_formula, - ) - return PatchResult( - out_path=str(output_path), - patch_diff=diff, - inverse_ops=[], - formula_issues=[], - warnings=warnings, - ) - except PatchOpError as exc: - return PatchResult( - out_path=str(output_path), - patch_diff=[], - inverse_ops=[], - formula_issues=[], - warnings=warnings, - error=exc.detail, - ) - except Exception as exc: - fallback = _maybe_fallback_openpyxl( - request, - resolved_input, - output_path, - warnings, - reason=f"COM patch failed; falling back to openpyxl. ({exc!r})", - ) - if fallback is not None: - return fallback - raise RuntimeError(f"COM patch failed: {exc}") from exc - - if com.reason: - warnings.append(f"COM unavailable: {com.reason}") - return _apply_with_openpyxl( - request, - resolved_input, - output_path, - warnings, - ) - - -def _apply_with_openpyxl( - request: PatchRequest, - input_path: Path, - output_path: Path, - warnings: list[str], -) -> PatchResult: - """Apply patch operations using openpyxl.""" - try: - diff, inverse_ops, formula_issues = _apply_ops_openpyxl( - request, - input_path, - output_path, - ) - except PatchOpError as exc: - return PatchResult( - out_path=str(output_path), - patch_diff=[], - inverse_ops=[], - formula_issues=[], - warnings=warnings, - error=exc.detail, - ) - except ValueError: - raise - except FileNotFoundError: - raise - except OSError: - raise - except Exception as exc: - raise RuntimeError(f"openpyxl patch failed: {exc}") from exc - - if not request.dry_run: - warnings.append( - "openpyxl editing may drop shapes/charts or unsupported elements." - ) - _append_skip_warnings(warnings, diff) - if ( - not request.dry_run - and request.preflight_formula_check - and any(issue.level == "error" for issue in formula_issues) - ): - issue = formula_issues[0] - op_index, op_name = _find_preflight_issue_origin(issue, request.ops) - error = PatchErrorDetail( - op_index=op_index, - op=op_name, - sheet=issue.sheet, - cell=issue.cell, - message=f"Formula health check failed: {issue.message}", - ) - return PatchResult( - out_path=str(output_path), - patch_diff=[], - inverse_ops=[], - formula_issues=formula_issues, - warnings=warnings, - error=error, - ) - return PatchResult( - out_path=str(output_path), - patch_diff=diff, - inverse_ops=inverse_ops, - formula_issues=formula_issues, - warnings=warnings, - ) - - -def _append_skip_warnings(warnings: list[str], diff: list[PatchDiffItem]) -> None: - """Append warning messages for skipped conditional operations.""" - for item in diff: - if item.status != "skipped": - continue - warnings.append( - f"Skipped op[{item.op_index}] {item.op} at {item.sheet}!{item.cell} due to condition mismatch." - ) - - -def _find_preflight_issue_origin( - issue: FormulaIssue, ops: list[PatchOp] -) -> tuple[int, PatchOpType]: - """Find the most likely op index/op name for a preflight formula issue.""" - for index, op in enumerate(ops): - if _op_targets_issue_cell(op, issue.sheet, issue.cell): - return index, op.op - return -1, "set_value" - - -def _op_targets_issue_cell(op: PatchOp, sheet: str, cell: str) -> bool: - """Return True when an op can affect the specified sheet/cell.""" - if op.sheet != sheet: - return False - if op.cell is not None: - return op.cell == cell - if op.range is None: - return False - for row in _expand_range_coordinates(op.range): - if cell in row: - return True - return False - - -def _maybe_fallback_openpyxl( - request: PatchRequest, - input_path: Path, - output_path: Path, - warnings: list[str], - *, - reason: str, -) -> PatchResult | None: - """Attempt openpyxl fallback after COM failure.""" - if input_path.suffix.lower() == ".xls": - warnings.append(reason) - return None - warnings.append(reason) - return _apply_with_openpyxl( - request, - input_path, - output_path, - warnings, - ) - - -def _requires_openpyxl_backend(request: PatchRequest) -> bool: - """Return True if request requires openpyxl backend for extended features.""" - if request.dry_run or request.return_inverse_ops or request.preflight_formula_check: - return True - return any( - op.op in {"set_range_values", "fill_formula", "set_value_if", "set_formula_if"} - for op in request.ops - ) - - -def _resolve_input_path(path: Path, *, policy: PathPolicy | None) -> Path: - """Resolve and validate the input path.""" - resolved = policy.ensure_allowed(path) if policy else path.resolve() - if not resolved.exists(): - raise FileNotFoundError(f"Input file not found: {resolved}") - if not resolved.is_file(): - raise ValueError(f"Input path is not a file: {resolved}") - return resolved - - -def _ensure_supported_extension(path: Path) -> None: - """Validate that the input file extension is supported.""" - if path.suffix.lower() not in _ALLOWED_EXTENSIONS: - raise ValueError(f"Unsupported file extension: {path.suffix}") - - -def _resolve_output_path( - input_path: Path, - *, - out_dir: Path | None, - out_name: str | None, - policy: PathPolicy | None, -) -> Path: - """Build and validate the output path.""" - target_dir = out_dir or input_path.parent - target_dir = policy.ensure_allowed(target_dir) if policy else target_dir.resolve() - name = _normalize_output_name(input_path, out_name) - output_path = (target_dir / name).resolve() - if policy is not None: - output_path = policy.ensure_allowed(output_path) - return output_path - - -def _normalize_output_name(input_path: Path, out_name: str | None) -> str: - """Normalize output filename with a safe suffix.""" - if out_name: - candidate = Path(out_name) - return ( - candidate.name - if candidate.suffix - else f"{candidate.name}{input_path.suffix}" - ) - return f"{input_path.stem}_patched{input_path.suffix}" - - -def _ensure_output_dir(path: Path) -> None: - """Ensure the output directory exists before writing.""" - path.parent.mkdir(parents=True, exist_ok=True) - - -def _apply_conflict_policy( - output_path: Path, on_conflict: OnConflictPolicy -) -> tuple[Path, str | None, bool]: - """Apply output conflict policy to a resolved output path.""" - if not output_path.exists(): - return output_path, None, False - if on_conflict == "skip": - return ( - output_path, - f"Output exists; skipping write: {output_path.name}", - True, - ) - if on_conflict == "rename": - renamed = _next_available_path(output_path) - return ( - renamed, - f"Output exists; renamed to: {renamed.name}", - False, - ) - return output_path, None, False - - -def _next_available_path(path: Path) -> Path: - """Return the next available path by appending a numeric suffix.""" - if not path.exists(): - return path - stem = path.stem - suffix = path.suffix - for idx in range(1, 10_000): - candidate = path.with_name(f"{stem}_{idx}{suffix}") - if not candidate.exists(): - return candidate - raise RuntimeError(f"Failed to resolve unique path for {path}") - - -def _apply_ops_openpyxl( - request: PatchRequest, - input_path: Path, - output_path: Path, -) -> tuple[list[PatchDiffItem], list[PatchOp], list[FormulaIssue]]: - """Apply operations using openpyxl.""" - try: - from openpyxl import load_workbook - except ImportError as exc: - raise RuntimeError(f"openpyxl is not available: {exc}") from exc - - if input_path.suffix.lower() == ".xls": - raise ValueError("openpyxl cannot edit .xls files.") - - if input_path.suffix.lower() == ".xlsm": - workbook = load_workbook(input_path, keep_vba=True) - else: - workbook = load_workbook(input_path) - try: - diff, inverse_ops = _apply_ops_to_openpyxl_workbook( - workbook, - request.ops, - request.auto_formula, - return_inverse_ops=request.return_inverse_ops, - ) - formula_issues = ( - _collect_formula_issues_openpyxl(workbook) - if request.preflight_formula_check - else [] - ) - if not request.dry_run and not ( - request.preflight_formula_check - and any(issue.level == "error" for issue in formula_issues) - ): - workbook.save(output_path) - finally: - workbook.close() - return diff, inverse_ops, formula_issues - - -def _apply_ops_to_openpyxl_workbook( - workbook: OpenpyxlWorkbookProtocol, - ops: list[PatchOp], - auto_formula: bool, - *, - return_inverse_ops: bool, -) -> tuple[list[PatchDiffItem], list[PatchOp]]: - """Apply ops to an openpyxl workbook instance.""" - sheets = _openpyxl_sheet_map(workbook) - diff: list[PatchDiffItem] = [] - inverse_ops: list[PatchOp] = [] - for index, op in enumerate(ops): - try: - item, inverse = _apply_openpyxl_op( - workbook, sheets, op, index, auto_formula - ) - diff.append(item) - if return_inverse_ops and item.status == "applied" and inverse is not None: - inverse_ops.append(inverse) - except ValueError as exc: - raise PatchOpError.from_op(index, op, exc) from exc - if return_inverse_ops: - inverse_ops.reverse() - return diff, inverse_ops - - -def _openpyxl_sheet_map( - workbook: OpenpyxlWorkbookProtocol, -) -> dict[str, OpenpyxlWorksheetProtocol]: - """Build a sheet map for openpyxl workbooks.""" - sheet_names = getattr(workbook, "sheetnames", None) - if not isinstance(sheet_names, list): - raise ValueError("Invalid workbook: sheetnames missing.") - return {name: workbook[name] for name in sheet_names} - - -def _apply_openpyxl_op( - workbook: OpenpyxlWorkbookProtocol, - sheets: dict[str, OpenpyxlWorksheetProtocol], - op: PatchOp, - index: int, - auto_formula: bool, -) -> tuple[PatchDiffItem, PatchOp | None]: - """Apply a single op to openpyxl workbook.""" - if op.op == "add_sheet": - return _apply_openpyxl_add_sheet(workbook, sheets, op, index) - - existing_sheet = sheets.get(op.sheet) - if existing_sheet is None: - raise ValueError(f"Sheet not found: {op.sheet}") - - if op.op == "set_range_values": - return _apply_openpyxl_set_range_values(existing_sheet, op, index) - - if op.op == "fill_formula": - return _apply_openpyxl_fill_formula(existing_sheet, op, index) - - if op.op in {"set_value", "set_formula", "set_value_if", "set_formula_if"}: - return _apply_openpyxl_cell_op(existing_sheet, op, index, auto_formula) - raise ValueError(f"Unsupported op: {op.op}") - - -def _apply_openpyxl_add_sheet( - workbook: OpenpyxlWorkbookProtocol, - sheets: dict[str, OpenpyxlWorksheetProtocol], - op: PatchOp, - index: int, -) -> tuple[PatchDiffItem, PatchOp | None]: - """Apply add_sheet op.""" - if op.sheet in sheets: - raise ValueError(f"Sheet already exists: {op.sheet}") - sheet = workbook.create_sheet(title=op.sheet) - sheets[op.sheet] = sheet - return ( - PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=None, - before=None, - after=PatchValue(kind="sheet", value=op.sheet), - ), - None, - ) - - -def _apply_openpyxl_set_range_values( - sheet: OpenpyxlWorksheetProtocol, - op: PatchOp, - index: int, -) -> tuple[PatchDiffItem, PatchOp | None]: - """Apply set_range_values op.""" - if op.range is None or op.values is None: - raise ValueError("set_range_values requires range and values.") - coordinates = _expand_range_coordinates(op.range) - rows, cols = _shape_of_coordinates(coordinates) - if len(op.values) != rows: - raise ValueError("set_range_values values height does not match range.") - if any(len(row) != cols for row in op.values): - raise ValueError("set_range_values values width does not match range.") - for r_idx, row in enumerate(coordinates): - for c_idx, coord in enumerate(row): - sheet[coord].value = op.values[r_idx][c_idx] - return ( - PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=op.range, - before=None, - after=PatchValue(kind="value", value=f"{rows}x{cols}"), - ), - None, - ) - - -def _apply_openpyxl_fill_formula( - sheet: OpenpyxlWorksheetProtocol, - op: PatchOp, - index: int, -) -> tuple[PatchDiffItem, PatchOp | None]: - """Apply fill_formula op.""" - if op.range is None or op.formula is None or op.base_cell is None: - raise ValueError("fill_formula requires range, base_cell and formula.") - coordinates = _expand_range_coordinates(op.range) - rows, cols = _shape_of_coordinates(coordinates) - if rows != 1 and cols != 1: - raise ValueError("fill_formula range must be a single row or a single column.") - for row in coordinates: - for coord in row: - sheet[coord].value = _translate_formula(op.formula, op.base_cell, coord) - return ( - PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=op.range, - before=None, - after=PatchValue(kind="formula", value=op.formula), - ), - None, - ) - - -def _apply_openpyxl_cell_op( - sheet: OpenpyxlWorksheetProtocol, - op: PatchOp, - index: int, - auto_formula: bool, -) -> tuple[PatchDiffItem, PatchOp | None]: - """Apply single-cell operations.""" - cell_ref = op.cell - if cell_ref is None: - raise ValueError(f"{op.op} requires cell.") - cell = sheet[cell_ref] - before = _openpyxl_cell_value(cell) - - if op.op == "set_value": - after = _set_cell_value(cell, op.value, auto_formula, op_name="set_value") - return _build_cell_result( - op, index, cell_ref, before, after - ), _build_inverse_cell_op(op, cell_ref, before) - if op.op == "set_formula": - formula = _require_formula(op.formula, "set_formula") - cell.value = formula - after = PatchValue(kind="formula", value=formula) - return _build_cell_result( - op, index, cell_ref, before, after - ), _build_inverse_cell_op(op, cell_ref, before) - if op.op == "set_value_if": - if not _values_equal_for_condition( - _patch_value_to_primitive(before), op.expected - ): - return _build_skipped_result(op, index, cell_ref, before), None - after = _set_cell_value(cell, op.value, auto_formula, op_name="set_value_if") - return _build_cell_result( - op, index, cell_ref, before, after - ), _build_inverse_cell_op(op, cell_ref, before) - formula_if = _require_formula(op.formula, "set_formula_if") - if not _values_equal_for_condition(_patch_value_to_primitive(before), op.expected): - return _build_skipped_result(op, index, cell_ref, before), None - cell.value = formula_if - after = PatchValue(kind="formula", value=formula_if) - return _build_cell_result( - op, index, cell_ref, before, after - ), _build_inverse_cell_op(op, cell_ref, before) - - -def _set_cell_value( - cell: OpenpyxlCellProtocol, - value: str | int | float | None, - auto_formula: bool, - *, - op_name: str, -) -> PatchValue: - """Set cell value with auto_formula handling.""" - if isinstance(value, str) and value.startswith("="): - if not auto_formula: - raise ValueError(f"{op_name} rejects values starting with '='.") - cell.value = value - return PatchValue(kind="formula", value=value) - cell.value = value - return PatchValue(kind="value", value=value) - - -def _build_cell_result( - op: PatchOp, - index: int, - cell_ref: str, - before: PatchValue | None, - after: PatchValue | None, -) -> PatchDiffItem: - """Build applied diff item for single-cell op.""" - return PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=cell_ref, - before=before, - after=after, - ) - - -def _build_skipped_result( - op: PatchOp, - index: int, - cell_ref: str, - before: PatchValue | None, -) -> PatchDiffItem: - """Build skipped diff item.""" - return PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=cell_ref, - before=before, - after=before, - status="skipped", - ) - - -def _require_formula(formula: str | None, op_name: str) -> str: - """Require a non-null formula string.""" - if formula is None: - raise ValueError(f"{op_name} requires formula.") - return formula - - -def _openpyxl_cell_value(cell: OpenpyxlCellProtocol) -> PatchValue | None: - """Normalize an openpyxl cell value into PatchValue.""" - value = getattr(cell, "value", None) - if value is None: - return None - data_type = getattr(cell, "data_type", None) - if data_type == "f": - text = _normalize_formula(value) - return PatchValue(kind="formula", value=text) - return PatchValue(kind="value", value=value) - - -def _normalize_formula(value: object) -> str: - """Ensure formula string starts with '='.""" - text = str(value) - return text if text.startswith("=") else f"={text}" - - -def _expand_range_coordinates(range_ref: str) -> list[list[str]]: - """Expand A1 range string into a 2D list of coordinates.""" - try: - from openpyxl.utils.cell import get_column_letter, range_boundaries - except ImportError as exc: - raise RuntimeError(f"openpyxl is not available: {exc}") from exc - min_col, min_row, max_col, max_row = range_boundaries(range_ref) - if min_col > max_col or min_row > max_row: - raise ValueError(f"Invalid range reference: {range_ref}") - rows: list[list[str]] = [] - for row_idx in range(min_row, max_row + 1): - row: list[str] = [] - for col_idx in range(min_col, max_col + 1): - row.append(f"{get_column_letter(col_idx)}{row_idx}") - rows.append(row) - return rows - - -def _shape_of_coordinates(coordinates: list[list[str]]) -> tuple[int, int]: - """Return rows/cols for expanded coordinates.""" - if not coordinates or not coordinates[0]: - raise ValueError("Range expansion resulted in an empty coordinate set.") - return len(coordinates), len(coordinates[0]) - - -def _translate_formula(formula: str, origin: str, target: str) -> str: - """Translate formula with relative references from origin to target.""" - try: - from openpyxl.formula.translate import Translator - except ImportError as exc: - raise RuntimeError(f"openpyxl is not available: {exc}") from exc - translated = Translator(formula, origin=origin).translate_formula(target) - return str(translated) - - -def _patch_value_to_primitive(value: PatchValue | None) -> str | int | float | None: - """Convert PatchValue into primitive value for condition checks.""" - if value is None: - return None - return value.value - - -def _values_equal_for_condition( - current: str | int | float | None, - expected: str | int | float | None, -) -> bool: - """Compare values for conditional update checks.""" - return current == expected - - -def _build_inverse_cell_op( - op: PatchOp, - cell_ref: str, - before: PatchValue | None, -) -> PatchOp | None: - """Build inverse operation for single-cell updates.""" - if op.op not in {"set_value", "set_formula", "set_value_if", "set_formula_if"}: - return None - if before is None: - return PatchOp(op="set_value", sheet=op.sheet, cell=cell_ref, value=None) - if before.kind == "formula": - return PatchOp( - op="set_formula", - sheet=op.sheet, - cell=cell_ref, - formula=str(before.value), - ) - return PatchOp(op="set_value", sheet=op.sheet, cell=cell_ref, value=before.value) - - -def _collect_formula_issues_openpyxl( - workbook: OpenpyxlWorkbookProtocol, -) -> list[FormulaIssue]: - """Collect simple formula issues by scanning formula text.""" - token_map: dict[str, tuple[FormulaIssueCode, FormulaIssueLevel]] = { - "#REF!": ("ref_error", "error"), - "#NAME?": ("name_error", "error"), - "#DIV/0!": ("div0_error", "error"), - "#VALUE!": ("value_error", "error"), - "#N/A": ("na_error", "warning"), - } - issues: list[FormulaIssue] = [] - for sheet_name in workbook.sheetnames: - sheet = workbook[sheet_name] - iter_rows = getattr(sheet, "iter_rows", None) - if iter_rows is None: - continue - for row in iter_rows(): - for cell in row: - raw = getattr(cell, "value", None) - if not isinstance(raw, str) or not raw.startswith("="): - continue - normalized = raw.upper() - if "==" in normalized: - issues.append( - FormulaIssue( - sheet=sheet_name, - cell=str(getattr(cell, "coordinate", "")), - level="warning", - code="invalid_token", - message="Formula contains duplicated '=' token.", - ) - ) - for token, (code, level) in token_map.items(): - if token in normalized: - issues.append( - FormulaIssue( - sheet=sheet_name, - cell=str(getattr(cell, "coordinate", "")), - level=level, - code=code, - message=f"Formula contains error token {token}.", - ) - ) - return issues - - -def _apply_ops_xlwings( - input_path: Path, - output_path: Path, - ops: list[PatchOp], - auto_formula: bool, -) -> list[PatchDiffItem]: - """Apply operations using Excel COM via xlwings.""" - diff: list[PatchDiffItem] = [] - try: - with _xlwings_workbook(input_path) as workbook: - sheets = {sheet.name: sheet for sheet in workbook.sheets} - for index, op in enumerate(ops): - try: - diff.append( - _apply_xlwings_op(workbook, sheets, op, index, auto_formula) - ) - except ValueError as exc: - raise PatchOpError.from_op(index, op, exc) from exc - workbook.save(str(output_path)) - except ValueError: - raise - except Exception as exc: - raise RuntimeError(f"COM patch failed: {exc}") from exc - return diff - - -def _apply_xlwings_op( - workbook: XlwingsWorkbookProtocol, - sheets: dict[str, XlwingsSheetProtocol], - op: PatchOp, - index: int, - auto_formula: bool, -) -> PatchDiffItem: - """Apply a single op to an xlwings workbook.""" - # Extended ops are routed to openpyxl by _requires_openpyxl_backend. - # Keep this explicit guard to prevent accidental path regressions. - if op.op not in {"add_sheet", "set_value", "set_formula"}: - raise ValueError(f"Unsupported op: {op.op}") - if op.op == "add_sheet": - if op.sheet in sheets: - raise ValueError(f"Sheet already exists: {op.sheet}") - last = workbook.sheets[-1] if workbook.sheets else None - sheet = workbook.sheets.add(name=op.sheet, after=last) - sheets[op.sheet] = sheet - return PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=None, - before=None, - after=PatchValue(kind="sheet", value=op.sheet), - ) - - existing_sheet = sheets.get(op.sheet) - if existing_sheet is None: - raise ValueError(f"Sheet not found: {op.sheet}") - cell_ref = op.cell - if cell_ref is None: - raise ValueError(f"{op.op} requires cell.") - rng = existing_sheet.range(cell_ref) - before = _xlwings_cell_value(rng) - if op.op == "set_value": - if isinstance(op.value, str) and op.value.startswith("="): - if not auto_formula: - raise ValueError("set_value rejects values starting with '='.") - rng.formula = op.value - after = PatchValue(kind="formula", value=op.value) - else: - rng.value = op.value - after = PatchValue(kind="value", value=op.value) - return PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=cell_ref, - before=before, - after=after, - ) - if op.op == "set_formula": - formula = op.formula - if formula is None: - raise ValueError("set_formula requires formula.") - rng.formula = formula - after = PatchValue(kind="formula", value=formula) - return PatchDiffItem( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=cell_ref, - before=before, - after=after, - ) - raise ValueError(f"Unsupported op: {op.op}") - - -def _xlwings_cell_value(cell: XlwingsRangeProtocol) -> PatchValue | None: - """Normalize an xlwings cell value into PatchValue.""" - formula = getattr(cell, "formula", None) - if isinstance(formula, str) and formula.startswith("="): - return PatchValue(kind="formula", value=formula) - value = getattr(cell, "value", None) - if value is None: - return None - return PatchValue(kind="value", value=value) - - -@contextmanager -def _xlwings_workbook(file_path: Path) -> Iterator[XlwingsWorkbookProtocol]: - """Open an Excel workbook with a dedicated COM app.""" - app = xw.App(add_book=False, visible=False) - app.display_alerts = False - app.screen_updating = False - workbook = app.books.open(str(file_path)) - try: - yield workbook - finally: - try: - workbook.close() - except Exception: - pass - try: - app.quit() - except Exception: - try: - app.kill() - except Exception: - pass - - -class PatchOpError(ValueError): - """Patch operation error with structured detail.""" - - def __init__(self, detail: PatchErrorDetail) -> None: - super().__init__(detail.message) - self.detail = detail - - @classmethod - def from_op(cls, index: int, op: PatchOp, exc: Exception) -> PatchOpError: - """Build a PatchOpError from an op and exception.""" - detail = PatchErrorDetail( - op_index=index, - op=op.op, - sheet=op.sheet, - cell=op.cell, - message=str(exc), - ) - return cls(detail) + """Compatibility wrapper for patch runner.""" + _sync_legacy_overrides() + return service.run_patch(request, policy=policy) + + +__all__ = [ + "AlignmentSnapshot", + "BorderSideSnapshot", + "BorderSnapshot", + "ColumnDimensionSnapshot", + "DesignSnapshot", + "FillSnapshot", + "FontSnapshot", + "FormulaIssue", + "MakeRequest", + "MergeStateSnapshot", + "OpenpyxlWorksheetProtocol", + "PatchDiffItem", + "PatchErrorDetail", + "PatchOp", + "PatchOpError", + "PatchRequest", + "PatchResult", + "PatchValue", + "RowDimensionSnapshot", + "XlwingsRangeProtocol", + "get_com_availability", + "run_make", + "run_patch", +] diff --git a/src/exstruct/mcp/server.py b/src/exstruct/mcp/server.py index 2051057..63d229c 100644 --- a/src/exstruct/mcp/server.py +++ b/src/exstruct/mcp/server.py @@ -3,10 +3,10 @@ import argparse import functools import importlib -import json import logging import os from pathlib import Path +import sys from types import ModuleType from typing import TYPE_CHECKING, Any, Literal, cast @@ -17,9 +17,20 @@ from .extract_runner import OnConflictPolicy from .io import PathPolicy +from .op_schema import build_patch_tool_mini_schema +from .patch.normalize import ( + build_patch_op_error_message as _normalize_build_patch_op_error_message, + coerce_patch_ops as _normalize_coerce_patch_ops, + parse_patch_op_json as _normalize_parse_patch_op_json, +) from .tools import ( + DescribeOpToolInput, + DescribeOpToolOutput, ExtractToolInput, ExtractToolOutput, + ListOpsToolOutput, + MakeToolInput, + MakeToolOutput, PatchToolInput, PatchToolOutput, ReadCellsToolInput, @@ -30,9 +41,13 @@ ReadJsonChunkToolOutput, ReadRangeToolInput, ReadRangeToolOutput, + RuntimeInfoToolOutput, ValidateInputToolInput, ValidateInputToolOutput, + run_describe_op_tool, run_extract_tool, + run_list_ops_tool, + run_make_tool, run_patch_tool, run_read_cells_tool, run_read_formulas_tool, @@ -57,6 +72,10 @@ class ServerConfig(BaseModel): on_conflict: OnConflictPolicy = Field( default="overwrite", description="Output conflict policy." ) + artifact_bridge_dir: Path | None = Field( + default=None, + description="Optional bridge directory for mirrored artifacts.", + ) warmup: bool = Field(default=False, description="Warm up heavy imports on start.") @@ -95,7 +114,11 @@ def run_server(config: ServerConfig) -> None: logger.info("MCP root: %s", policy.normalize_root()) if config.warmup: _warmup_exstruct() - app = _create_app(policy, on_conflict=config.on_conflict) + app = _create_app( + policy, + on_conflict=config.on_conflict, + artifact_bridge_dir=config.artifact_bridge_dir, + ) app.run() @@ -128,6 +151,11 @@ def _parse_args(argv: list[str] | None) -> ServerConfig: default="overwrite", help="Output conflict policy (overwrite/skip/rename).", ) + parser.add_argument( + "--artifact-bridge-dir", + type=Path, + help="Optional directory to mirror generated artifacts for chat handoff.", + ) parser.add_argument( "--warmup", action="store_true", @@ -140,6 +168,7 @@ def _parse_args(argv: list[str] | None) -> ServerConfig: log_level=args.log_level, log_file=args.log_file, on_conflict=args.on_conflict, + artifact_bridge_dir=args.artifact_bridge_dir, warmup=bool(args.warmup), ) @@ -182,7 +211,12 @@ def _warmup_exstruct() -> None: logger.info("Warmup completed.") -def _create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> FastMCP: +def _create_app( + policy: PathPolicy, + *, + on_conflict: OnConflictPolicy, + artifact_bridge_dir: Path | None = None, +) -> FastMCP: """Create the MCP FastMCP application. Args: @@ -194,18 +228,29 @@ def _create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> FastMCP from mcp.server.fastmcp import FastMCP app = FastMCP("ExStruct MCP", json_response=True) - _register_tools(app, policy, default_on_conflict=on_conflict) + _register_tools( + app, + policy, + default_on_conflict=on_conflict, + artifact_bridge_dir=artifact_bridge_dir, + ) return app def _register_tools( - app: FastMCP, policy: PathPolicy, *, default_on_conflict: OnConflictPolicy + app: FastMCP, + policy: PathPolicy, + *, + default_on_conflict: OnConflictPolicy, + artifact_bridge_dir: Path | None = None, ) -> None: """Register MCP tools for the server. Args: app: FastMCP application instance. policy: Path policy for filesystem access. + default_on_conflict: Default conflict policy used when tool input omits it. + artifact_bridge_dir: Optional directory for artifact mirroring handoff. """ async def _extract_tool( # pylint: disable=redefined-builtin @@ -426,9 +471,32 @@ async def _validate_input_tool(xlsx_path: str) -> ValidateInputToolOutput: validate_tool = app.tool(name="exstruct_validate_input") validate_tool(_validate_input_tool) + async def _runtime_info_tool() -> RuntimeInfoToolOutput: + """Return runtime diagnostics for MCP path troubleshooting. + + Returns: + Runtime root/cwd/platform and valid path examples. + """ + root = policy.normalize_root() + return RuntimeInfoToolOutput( + root=str(root), + cwd=str(Path.cwd().resolve()), + platform=sys.platform, + path_examples={ + "relative": "outputs/book.xlsx", + "absolute": str(root / "outputs" / "book.xlsx"), + }, + ) + + runtime_info_tool = app.tool(name="exstruct_get_runtime_info") + runtime_info_tool(_runtime_info_tool) + + _register_op_schema_tools(app) + async def _patch_tool( xlsx_path: str, ops: list[dict[str, Any] | str], + sheet: str | None = None, out_dir: str | None = None, out_name: str | None = None, on_conflict: OnConflictPolicy | None = None, @@ -436,6 +504,8 @@ async def _patch_tool( dry_run: bool = False, return_inverse_ops: bool = False, preflight_formula_check: bool = False, + backend: Literal["auto", "com", "openpyxl"] = "auto", + mirror_artifact: bool = False, ) -> PatchToolOutput: """Edit an Excel workbook by applying patch operations. @@ -453,7 +523,22 @@ async def _patch_tool( 'add_sheet' (create new sheet), 'set_range_values' (bulk set rectangular range), 'fill_formula' (fill formula across a row/column), 'set_value_if' (conditional value update), - 'set_formula_if' (conditional formula update). + 'set_formula_if' (conditional formula update), + 'draw_grid_border' (draw thin black grid border), + 'set_bold' (apply bold style), + 'set_font_size' (apply font size; requires font_size > 0 and exactly one of cell/range), + 'set_font_color' (apply font color; requires color and exactly one of cell/range), + 'set_fill_color' (apply solid fill), + 'set_dimensions' (set row height/column width), + 'auto_fit_columns' (auto-fit column widths with optional bounds), + 'merge_cells' (merge a rectangular range), + 'unmerge_cells' (unmerge ranges intersecting target), + 'set_alignment' (set horizontal/vertical alignment and wrap_text), and + 'set_style' (apply multiple style attributes in one op), and + 'apply_table_style' (create table and apply Excel table style), and + 'restore_design_snapshot' (internal inverse restore op). + sheet: Optional default sheet name. Used when op.sheet is omitted + for non-add_sheet ops. If both are set, op.sheet wins. out_dir: Output directory. Defaults to same directory as input. out_name: Output filename. Defaults to '{stem}_patched{ext}'. on_conflict: Conflict policy when output file exists: @@ -465,6 +550,15 @@ async def _patch_tool( return_inverse_ops: When true, return inverse (undo) operations. preflight_formula_check: When true, scan formulas for errors like #REF!, #NAME?, #DIV/0! before saving. + backend: Patch execution backend. + - "auto" (default): prefer COM when available; otherwise openpyxl. + Uses openpyxl when dry_run/return_inverse_ops/preflight_formula_check + is enabled. + - "com": force COM path (requires Excel COM and disallows + dry_run/return_inverse_ops/preflight_formula_check). + - "openpyxl": force openpyxl path (.xls is not supported). + mirror_artifact: When true, mirror output workbook to + --artifact-bridge-dir after successful patch. Returns: Patch result with output path, applied diffs, and any warnings. @@ -475,25 +569,176 @@ async def _patch_tool( ops=normalized_ops, out_dir=out_dir, out_name=out_name, + sheet=sheet, on_conflict=on_conflict, auto_formula=auto_formula, dry_run=dry_run, return_inverse_ops=return_inverse_ops, preflight_formula_check=preflight_formula_check, + backend=backend, + mirror_artifact=mirror_artifact, ) effective_on_conflict = on_conflict or default_on_conflict - work = functools.partial( - run_patch_tool, - payload, - policy=policy, - on_conflict=effective_on_conflict, - ) + if artifact_bridge_dir is None: + work = functools.partial( + run_patch_tool, + payload, + policy=policy, + on_conflict=effective_on_conflict, + ) + else: + work = functools.partial( + run_patch_tool, + payload, + policy=policy, + on_conflict=effective_on_conflict, + artifact_bridge_dir=artifact_bridge_dir, + ) result = cast(PatchToolOutput, await anyio.to_thread.run_sync(work)) return result + _patch_tool.__doc__ = _build_patch_tool_description() + patch_tool = app.tool(name="exstruct_patch") patch_tool(_patch_tool) + async def _make_tool( + out_path: str, + ops: list[dict[str, Any] | str] | None = None, + sheet: str | None = None, + on_conflict: OnConflictPolicy | None = None, + auto_formula: bool = False, + dry_run: bool = False, + return_inverse_ops: bool = False, + preflight_formula_check: bool = False, + backend: Literal["auto", "com", "openpyxl"] = "auto", + mirror_artifact: bool = False, + ) -> MakeToolOutput: + """Create a new Excel workbook and apply patch operations. + + Args: + out_path: Output workbook path (.xlsx/.xlsm/.xls). + ops: Optional patch operations. Accepts object list or JSON object strings. + sheet: Optional default sheet name. Used when op.sheet is omitted + for non-add_sheet ops. If both are set, op.sheet wins. + on_conflict: Conflict policy when output file exists: + 'overwrite' (replace), 'skip' (do nothing), 'rename' (auto-rename). + Defaults to server --on-conflict setting. + auto_formula: When true, values starting with '=' in set_value ops + are treated as formulas instead of being rejected. + dry_run: When true, compute diff without saving changes. + return_inverse_ops: When true, return inverse (undo) operations. + preflight_formula_check: When true, scan formulas for errors + like #REF!, #NAME?, #DIV/0! before saving. + backend: Patch execution backend. + - "auto" (default): prefer COM when available; otherwise openpyxl. + - "com": force COM path (requires Excel COM). + - "openpyxl": force openpyxl path (.xls is not supported). + mirror_artifact: When true, mirror output workbook to + --artifact-bridge-dir after successful make/patch. + + Returns: + Patch-compatible result with output path, diff, and warnings. + """ + normalized_ops = _coerce_patch_ops(ops or []) + payload = MakeToolInput( + out_path=out_path, + ops=normalized_ops, + sheet=sheet, + on_conflict=on_conflict, + auto_formula=auto_formula, + dry_run=dry_run, + return_inverse_ops=return_inverse_ops, + preflight_formula_check=preflight_formula_check, + backend=backend, + mirror_artifact=mirror_artifact, + ) + effective_on_conflict = on_conflict or default_on_conflict + if artifact_bridge_dir is None: + work = functools.partial( + run_make_tool, + payload, + policy=policy, + on_conflict=effective_on_conflict, + ) + else: + work = functools.partial( + run_make_tool, + payload, + policy=policy, + on_conflict=effective_on_conflict, + artifact_bridge_dir=artifact_bridge_dir, + ) + result = cast(MakeToolOutput, await anyio.to_thread.run_sync(work)) + return result + + make_tool = app.tool(name="exstruct_make") + make_tool(_make_tool) + + +def _build_patch_tool_description() -> str: + """Build exstruct_patch tool description with op mini schema.""" + base_description = """ +Edit an Excel workbook by applying patch operations. + +Supports cell value updates, formula updates, and adding new sheets. +Operations are applied atomically: all succeed or none are saved. + +Args: + xlsx_path: Path to the Excel workbook to edit. + ops: Patch operations to apply in order. Preferred format is an + object list (one object per operation). For compatibility with + clients that cannot send object arrays, JSON object strings are + also accepted and normalized before validation. + sheet: Optional default sheet name. Used when op.sheet is omitted + for non-add_sheet ops. If both are set, op.sheet wins. + out_dir: Output directory. Defaults to same directory as input. + out_name: Output filename. Defaults to '{stem}_patched{ext}'. + on_conflict: Conflict policy when output file exists: + 'overwrite' (replace), 'skip' (do nothing), 'rename' (auto-rename). + Defaults to server --on-conflict setting. + auto_formula: When true, values starting with '=' in set_value ops + are treated as formulas instead of being rejected. + dry_run: When true, compute diff without saving changes. + return_inverse_ops: When true, return inverse (undo) operations. + preflight_formula_check: When true, scan formulas for errors + like #REF!, #NAME?, #DIV/0! before saving. + backend: Patch execution backend. + - "auto" (default): prefer COM when available; otherwise openpyxl. + Uses openpyxl when dry_run/return_inverse_ops/preflight_formula_check + is enabled. + - "com": force COM path (requires Excel COM and disallows + dry_run/return_inverse_ops/preflight_formula_check). + - "openpyxl": force openpyxl path (.xls is not supported). + mirror_artifact: When true, mirror output workbook to + --artifact-bridge-dir after successful patch. + +Returns: + Patch result with output path, applied diffs, and any warnings. +""" + return f"{base_description.strip()}\n\n{build_patch_tool_mini_schema()}" + + +def _register_op_schema_tools(app: FastMCP) -> None: + """Register schema discovery tools.""" + + async def _list_ops_tool() -> ListOpsToolOutput: + """List all patch op names with short descriptions.""" + return run_list_ops_tool() + + async def _describe_op_tool(op: str) -> DescribeOpToolOutput: + """Describe one patch op. + + Returns required/optional fields, constraints, example, and aliases. + """ + payload = DescribeOpToolInput(op=op) + return run_describe_op_tool(payload) + + list_ops_tool = app.tool(name="exstruct_list_ops") + list_ops_tool(_list_ops_tool) + describe_op_tool = app.tool(name="exstruct_describe_op") + describe_op_tool(_describe_op_tool) + def _coerce_filter(filter_data: dict[str, Any] | None) -> dict[str, Any] | None: """Normalize filter input for chunk reading. @@ -521,13 +766,7 @@ def _coerce_patch_ops(ops_data: list[dict[str, Any] | str]) -> list[dict[str, An Raises: ValueError: If a string op is not valid JSON object. """ - normalized_ops: list[dict[str, Any]] = [] - for index, raw_op in enumerate(ops_data): - if isinstance(raw_op, dict): - normalized_ops.append(dict(raw_op)) - continue - normalized_ops.append(_parse_patch_op_json(raw_op, index)) - return normalized_ops + return _normalize_coerce_patch_ops(ops_data) def _parse_patch_op_json(raw_op: str, index: int) -> dict[str, Any]: @@ -543,18 +782,7 @@ def _parse_patch_op_json(raw_op: str, index: int) -> dict[str, Any]: Raises: ValueError: If the string is not valid JSON object. """ - text = raw_op.strip() - if not text: - raise ValueError(_build_patch_op_error_message(index, "empty string")) - try: - parsed = json.loads(text) - except json.JSONDecodeError as exc: - raise ValueError(_build_patch_op_error_message(index, "invalid JSON")) from exc - if not isinstance(parsed, dict): - raise ValueError( - _build_patch_op_error_message(index, "JSON value must be an object") - ) - return cast(dict[str, Any], parsed) + return _normalize_parse_patch_op_json(raw_op, index=index) def _build_patch_op_error_message(index: int, reason: str) -> str: @@ -567,8 +795,4 @@ def _build_patch_op_error_message(index: int, reason: str) -> str: Returns: Human-readable error message. """ - example = '{"op":"set_value","sheet":"Sheet1","cell":"A1","value":"sample"}' - return ( - f"Invalid patch operation at ops[{index}]: {reason}. " - f"Use object form like {example}." - ) + return _normalize_build_patch_op_error_message(index, reason) diff --git a/src/exstruct/mcp/shared/__init__.py b/src/exstruct/mcp/shared/__init__.py new file mode 100644 index 0000000..b0e6416 --- /dev/null +++ b/src/exstruct/mcp/shared/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from .a1 import ( + column_index_to_label, + column_label_to_index, + normalize_range, + parse_range_geometry, + range_cell_count, + split_a1, +) +from .output_path import apply_conflict_policy, next_available_path, resolve_output_path + +__all__ = [ + "apply_conflict_policy", + "column_index_to_label", + "column_label_to_index", + "next_available_path", + "normalize_range", + "parse_range_geometry", + "range_cell_count", + "resolve_output_path", + "split_a1", +] diff --git a/src/exstruct/mcp/shared/a1.py b/src/exstruct/mcp/shared/a1.py new file mode 100644 index 0000000..aa03184 --- /dev/null +++ b/src/exstruct/mcp/shared/a1.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import re + +_A1_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*$") +_A1_RANGE_PATTERN = re.compile(r"^[A-Za-z]{1,3}[1-9][0-9]*:[A-Za-z]{1,3}[1-9][0-9]*$") +_COLUMN_LABEL_PATTERN = re.compile(r"^[A-Za-z]{1,3}$") + + +def split_a1(value: str) -> tuple[str, int]: + """Split A1 notation into normalized (column_label, row_index).""" + if not _A1_PATTERN.match(value): + raise ValueError(f"Invalid cell reference: {value}") + idx = 0 + for index, char in enumerate(value): + if char.isdigit(): + idx = index + break + column = value[:idx].upper() + row = int(value[idx:]) + return column, row + + +def column_label_to_index(label: str) -> int: + """Convert Excel-style column label (A/AA) to 1-based index.""" + normalized = label.strip().upper() + if not _COLUMN_LABEL_PATTERN.match(normalized): + raise ValueError(f"Invalid column label: {label}") + index = 0 + for char in normalized: + index = index * 26 + (ord(char) - ord("A") + 1) + return index + + +def column_index_to_label(index: int) -> str: + """Convert 1-based column index to Excel-style column label.""" + if index < 1: + raise ValueError("Column index must be positive.") + chunks: list[str] = [] + current = index + while current > 0: + current -= 1 + chunks.append(chr(ord("A") + (current % 26))) + current //= 26 + return "".join(reversed(chunks)) + + +def range_cell_count(range_ref: str) -> int: + """Return the number of cells represented by an A1 range.""" + start, end = normalize_range(range_ref).split(":", maxsplit=1) + start_col, start_row = split_a1(start) + end_col, end_row = split_a1(end) + min_col = min(column_label_to_index(start_col), column_label_to_index(end_col)) + max_col = max(column_label_to_index(start_col), column_label_to_index(end_col)) + min_row = min(start_row, end_row) + max_row = max(start_row, end_row) + return (max_col - min_col + 1) * (max_row - min_row + 1) + + +def normalize_range(value: str) -> str: + """Validate and normalize an A1 range string.""" + candidate = value.strip() + if not _A1_RANGE_PATTERN.match(candidate): + raise ValueError(f"Invalid range reference: {value}") + start, end = candidate.split(":", maxsplit=1) + return f"{start.upper()}:{end.upper()}" + + +def parse_range_geometry(range_ref: str) -> tuple[str, int, int]: + """Parse A1 range and return top-left cell + (rows, cols).""" + start_ref, end_ref = normalize_range(range_ref).split(":", maxsplit=1) + start_col, start_row = split_a1(start_ref) + end_col, end_row = split_a1(end_ref) + min_col = min(column_label_to_index(start_col), column_label_to_index(end_col)) + max_col = max(column_label_to_index(start_col), column_label_to_index(end_col)) + min_row = min(start_row, end_row) + max_row = max(start_row, end_row) + return ( + f"{column_index_to_label(min_col)}{min_row}", + max_row - min_row + 1, + max_col - min_col + 1, + ) diff --git a/src/exstruct/mcp/shared/output_path.py b/src/exstruct/mcp/shared/output_path.py new file mode 100644 index 0000000..72363d2 --- /dev/null +++ b/src/exstruct/mcp/shared/output_path.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from exstruct.mcp.io import PathPolicy + +OnConflictPolicy = Literal["overwrite", "skip", "rename"] + + +def resolve_output_path( + input_path: Path, + *, + out_dir: Path | None, + out_name: str | None, + policy: PathPolicy | None, + default_suffix: str, + default_name_builder: Literal["same_stem", "patched"] = "same_stem", +) -> Path: + """Build and validate an output path from input and optional overrides.""" + target_dir = out_dir or input_path.parent + target_dir = policy.ensure_allowed(target_dir) if policy else target_dir.resolve() + name = normalize_output_name( + input_path, + out_name, + default_suffix=default_suffix, + default_name_builder=default_name_builder, + ) + output_path = (target_dir / name).resolve() + if policy is not None: + output_path = policy.ensure_allowed(output_path) + return output_path + + +def normalize_output_name( + input_path: Path, + out_name: str | None, + *, + default_suffix: str, + default_name_builder: Literal["same_stem", "patched"], +) -> str: + """Normalize output filename with extension fallback behavior.""" + if out_name: + candidate = Path(out_name) + return ( + candidate.name if candidate.suffix else f"{candidate.name}{default_suffix}" + ) + if default_name_builder == "patched": + return f"{input_path.stem}_patched{default_suffix}" + return f"{input_path.stem}{default_suffix}" + + +def apply_conflict_policy( + output_path: Path, on_conflict: OnConflictPolicy +) -> tuple[Path, str | None, bool]: + """Apply output conflict policy to a resolved output path.""" + if not output_path.exists(): + return output_path, None, False + if on_conflict == "skip": + return ( + output_path, + f"Output exists; skipping write: {output_path.name}", + True, + ) + if on_conflict == "rename": + renamed = next_available_path(output_path) + return ( + renamed, + f"Output exists; renamed to: {renamed.name}", + False, + ) + return output_path, None, False + + +def next_available_path(path: Path) -> Path: + """Return the next available path by appending a numeric suffix.""" + if not path.exists(): + return path + stem = path.stem + suffix = path.suffix + for idx in range(1, 10_000): + candidate = path.with_name(f"{stem}_{idx}{suffix}") + if not candidate.exists(): + return candidate + raise RuntimeError(f"Failed to resolve unique path for {path}") diff --git a/src/exstruct/mcp/tools.py b/src/exstruct/mcp/tools.py index cd2377e..e825037 100644 --- a/src/exstruct/mcp/tools.py +++ b/src/exstruct/mcp/tools.py @@ -1,9 +1,11 @@ from __future__ import annotations from pathlib import Path +import shutil from typing import Literal +from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator from exstruct import ExtractionMode @@ -22,13 +24,24 @@ run_extract, ) from .io import PathPolicy +from .op_schema import ( + get_patch_op_schema, + list_patch_op_schemas, + schema_with_sheet_resolution_rules, +) +from .patch.normalize import ( + build_missing_sheet_message as _normalize_build_missing_sheet_message, + resolve_top_level_sheet_for_payload as _normalize_resolve_top_level_sheet_for_payload, +) from .patch_runner import ( FormulaIssue, + MakeRequest, PatchDiffItem, PatchErrorDetail, PatchOp, PatchRequest, PatchResult, + run_make, run_patch, ) from .sheet_reader import ( @@ -163,11 +176,58 @@ class ValidateInputToolOutput(BaseModel): errors: list[str] = Field(default_factory=list) +class RuntimePathExamples(BaseModel): + """Path examples for MCP runtime diagnostics.""" + + relative: str + absolute: str + + +class RuntimeInfoToolOutput(BaseModel): + """MCP tool output for runtime environment information.""" + + root: str + cwd: str + platform: str + path_examples: RuntimePathExamples + + +class OpSummary(BaseModel): + """Short op metadata for list output.""" + + op: str + description: str + + +class ListOpsToolOutput(BaseModel): + """MCP tool output for listing supported patch operations.""" + + ops: list[OpSummary] = Field(default_factory=list) + + +class DescribeOpToolInput(BaseModel): + """MCP tool input for describing one patch op.""" + + op: str + + +class DescribeOpToolOutput(BaseModel): + """MCP tool output for patch op schema details.""" + + op: str + required: list[str] = Field(default_factory=list) + optional: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + example: dict[str, object] = Field(default_factory=dict) + aliases: dict[str, str] = Field(default_factory=dict) + + class PatchToolInput(BaseModel): """MCP tool input for patching Excel files.""" xlsx_path: str ops: list[PatchOp] + sheet: str | None = None out_dir: str | None = None out_name: str | None = None on_conflict: OnConflictPolicy | None = None @@ -175,6 +235,53 @@ class PatchToolInput(BaseModel): dry_run: bool = False return_inverse_ops: bool = False preflight_formula_check: bool = False + backend: Literal["auto", "com", "openpyxl"] = "auto" + mirror_artifact: bool = False + + @field_validator("sheet") + @classmethod + def _validate_sheet(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not candidate: + raise ValueError("sheet must not be empty when provided.") + return candidate + + @model_validator(mode="before") + @classmethod + def _fill_ops_sheet_from_top_level(cls, data: object) -> object: + return _resolve_top_level_sheet_for_payload(data) + + +class MakeToolInput(BaseModel): + """MCP tool input for creating and patching new Excel files.""" + + out_path: str + ops: list[PatchOp] = Field(default_factory=list) + sheet: str | None = None + on_conflict: OnConflictPolicy | None = None + auto_formula: bool = False + dry_run: bool = False + return_inverse_ops: bool = False + preflight_formula_check: bool = False + backend: Literal["auto", "com", "openpyxl"] = "auto" + mirror_artifact: bool = False + + @field_validator("sheet") + @classmethod + def _validate_sheet(cls, value: str | None) -> str | None: + if value is None: + return None + candidate = value.strip() + if not candidate: + raise ValueError("sheet must not be empty when provided.") + return candidate + + @model_validator(mode="before") + @classmethod + def _fill_ops_sheet_from_top_level(cls, data: object) -> object: + return _resolve_top_level_sheet_for_payload(data) class PatchToolOutput(BaseModel): @@ -185,7 +292,22 @@ class PatchToolOutput(BaseModel): inverse_ops: list[PatchOp] = Field(default_factory=list) formula_issues: list[FormulaIssue] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) + mirrored_out_path: str | None = None + error: PatchErrorDetail | None = None + engine: Literal["com", "openpyxl"] + + +class MakeToolOutput(BaseModel): + """MCP tool output for workbook creation and patching.""" + + out_path: str + patch_diff: list[PatchDiffItem] = Field(default_factory=list) + inverse_ops: list[PatchOp] = Field(default_factory=list) + formula_issues: list[FormulaIssue] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + mirrored_out_path: str | None = None error: PatchErrorDetail | None = None + engine: Literal["com", "openpyxl"] def run_extract_tool( @@ -329,6 +451,7 @@ def run_patch_tool( *, policy: PathPolicy | None = None, on_conflict: OnConflictPolicy | None = None, + artifact_bridge_dir: Path | None = None, ) -> PatchToolOutput: """Run the patch tool handler. @@ -343,6 +466,7 @@ def run_patch_tool( request = PatchRequest( xlsx_path=Path(payload.xlsx_path), ops=payload.ops, + sheet=payload.sheet, out_dir=Path(payload.out_dir) if payload.out_dir else None, out_name=payload.out_name, on_conflict=payload.on_conflict or on_conflict or "overwrite", @@ -350,9 +474,56 @@ def run_patch_tool( dry_run=payload.dry_run, return_inverse_ops=payload.return_inverse_ops, preflight_formula_check=payload.preflight_formula_check, + backend=payload.backend, ) result = run_patch(request, policy=policy) - return _to_patch_tool_output(result) + output = _to_patch_tool_output(result) + _apply_artifact_mirroring( + output, + out_path=result.out_path, + mirror_artifact=payload.mirror_artifact, + artifact_bridge_dir=artifact_bridge_dir, + ) + return output + + +def run_make_tool( + payload: MakeToolInput, + *, + policy: PathPolicy | None = None, + on_conflict: OnConflictPolicy | None = None, + artifact_bridge_dir: Path | None = None, +) -> MakeToolOutput: + """Run the make tool handler. + + Args: + payload: Tool input payload. + policy: Optional path policy for access control. + on_conflict: Optional conflict policy override. + + Returns: + Tool output payload. + """ + request = MakeRequest( + out_path=Path(payload.out_path), + ops=payload.ops, + sheet=payload.sheet, + on_conflict=payload.on_conflict or on_conflict or "overwrite", + auto_formula=payload.auto_formula, + dry_run=payload.dry_run, + return_inverse_ops=payload.return_inverse_ops, + preflight_formula_check=payload.preflight_formula_check, + backend=payload.backend, + ) + result = run_make(request, policy=policy) + output = _to_make_tool_output(result) + _apply_artifact_mirroring( + output, + out_path=result.out_path, + mirror_artifact=payload.mirror_artifact, + artifact_bridge_dir=artifact_bridge_dir, + ) + return output def _to_tool_output(result: ExtractResult) -> ExtractToolOutput: @@ -464,6 +635,66 @@ def _to_validate_input_output( ) +def run_list_ops_tool() -> ListOpsToolOutput: + """Return available patch operations and their short descriptions.""" + return ListOpsToolOutput( + ops=[ + OpSummary(op=schema.op, description=schema.description) + for schema in list_patch_op_schemas() + ] + ) + + +def run_describe_op_tool(payload: DescribeOpToolInput) -> DescribeOpToolOutput: + """Return schema details for one patch operation. + + Args: + payload: Tool input payload. + + Returns: + Detailed op schema output. + + Raises: + ValueError: If op name is unknown. + """ + schema = get_patch_op_schema(payload.op) + if schema is None: + raise ValueError( + f"Unknown op '{payload.op}'. Use exstruct_list_ops to inspect available ops." + ) + display_schema = schema_with_sheet_resolution_rules(schema) + return DescribeOpToolOutput( + op=schema.op, + required=display_schema.required, + optional=display_schema.optional, + constraints=display_schema.constraints, + example=dict(display_schema.example), + aliases=dict(display_schema.aliases), + ) + + +def _resolve_top_level_sheet_for_payload(data: object) -> object: + """Resolve top-level sheet default into operation dict payloads.""" + return _normalize_resolve_top_level_sheet_for_payload(data) + + +def _normalize_top_level_sheet(value: object) -> str | None: + """Normalize optional top-level sheet text.""" + if value is None: + return None + if not isinstance(value, str): + return None + candidate = value.strip() + if not candidate: + return None + return candidate + + +def _build_missing_sheet_message(*, index: int, op_name: str) -> str: + """Build self-healing error for unresolved sheet selection.""" + return _normalize_build_missing_sheet_message(index=index, op_name=op_name) + + def _to_patch_tool_output(result: PatchResult) -> PatchToolOutput: """Convert internal result to patch tool output. @@ -480,4 +711,73 @@ def _to_patch_tool_output(result: PatchResult) -> PatchToolOutput: formula_issues=result.formula_issues, warnings=result.warnings, error=result.error, + engine=result.engine, + ) + + +def _to_make_tool_output(result: PatchResult) -> MakeToolOutput: + """Convert internal result to make tool output. + + Args: + result: Internal make result. + + Returns: + Tool output payload. + """ + return MakeToolOutput( + out_path=result.out_path, + patch_diff=result.patch_diff, + inverse_ops=result.inverse_ops, + formula_issues=result.formula_issues, + warnings=result.warnings, + mirrored_out_path=None, + error=result.error, + engine=result.engine, + ) + + +def _apply_artifact_mirroring( + output: PatchToolOutput | MakeToolOutput, + *, + out_path: str, + mirror_artifact: bool, + artifact_bridge_dir: Path | None, +) -> None: + """Apply optional artifact mirroring and append warnings when needed.""" + output.mirrored_out_path = None + if not mirror_artifact or output.error is not None: + return + mirrored_path, warning = _mirror_artifact( + source_path=Path(out_path), + artifact_bridge_dir=artifact_bridge_dir, ) + output.mirrored_out_path = mirrored_path + if warning is not None: + output.warnings.append(warning) + + +def _mirror_artifact( + *, source_path: Path, artifact_bridge_dir: Path | None +) -> tuple[str | None, str | None]: + """Mirror output artifact to bridge directory.""" + if artifact_bridge_dir is None: + return None, "mirror_artifact=true but --artifact-bridge-dir is not configured." + if not source_path.exists() or not source_path.is_file(): + return ( + None, + f"mirror_artifact requested, but output file was not found: {source_path}", + ) + try: + artifact_bridge_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return None, f"Failed to prepare artifact bridge directory: {exc}" + target = artifact_bridge_dir / source_path.name + if target.exists(): + target = artifact_bridge_dir / ( + f"{source_path.stem}_{uuid4().hex[:8]}{source_path.suffix}" + ) + try: + shutil.copy2(source_path, target) + except OSError as exc: + return None, f"Failed to mirror artifact: {exc}" + return str(target), None diff --git a/tests/core/test_mode_output.py b/tests/core/test_mode_output.py index ac70782..5ba4807 100644 --- a/tests/core/test_mode_output.py +++ b/tests/core/test_mode_output.py @@ -80,6 +80,7 @@ def _boom(*_a: object, **_k: object) -> Never: assert sheet.charts == [] +@pytest.mark.com # type: ignore[misc] def test_standardモードはテキストなし図形を除外する(tmp_path: Path) -> None: _ensure_excel() path = tmp_path / "shapes.xlsx" @@ -97,6 +98,7 @@ def test_standardモードはテキストなし図形を除外する(tmp_path: P assert s.direction is not None or s.begin_arrow_style is not None +@pytest.mark.com # type: ignore[misc] def test_verboseモードでは全図形と幅高さが出力される(tmp_path: Path) -> None: _ensure_excel() path = tmp_path / "shapes.xlsx" diff --git a/tests/mcp/patch/test_legacy_runner_ops.py b/tests/mcp/patch/test_legacy_runner_ops.py new file mode 100644 index 0000000..47864df --- /dev/null +++ b/tests/mcp/patch/test_legacy_runner_ops.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook, load_workbook +import pytest + +from exstruct.cli.availability import ComAvailability +from exstruct.mcp.io import PathPolicy +from exstruct.mcp.patch import internal as legacy_runner +from exstruct.mcp.patch.internal import PatchOp, PatchRequest + + +def _create_workbook(path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "Sheet1" + sheet["A1"] = "old" + sheet["B1"] = 1 + workbook.save(path) + workbook.close() + + +def _disable_com(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + legacy_runner, + "get_com_availability", + lambda: ComAvailability(available=False, reason="test"), + ) + + +def test_run_patch_auto_fit_columns_openpyxl_uses_single_pass_collector( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + sheet = workbook["Sheet1"] + sheet["A1"] = "a" + sheet["B1"] = "bbbbbbbb" + sheet["C1"] = "cccccccccccc" + workbook.save(input_path) + finally: + workbook.close() + + call_count = 0 + original = legacy_runner._collect_openpyxl_target_column_max_lengths + + def _counting_collector( + sheet: legacy_runner.OpenpyxlWorksheetProtocol, target_indexes: set[int] + ) -> dict[int, int]: + nonlocal call_count + call_count += 1 + return original(sheet, target_indexes) + + monkeypatch.setattr( + legacy_runner, + "_collect_openpyxl_target_column_max_lengths", + _counting_collector, + ) + + result = legacy_runner.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="auto_fit_columns", + sheet="Sheet1", + columns=["A", "B", "C"], + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + + assert result.error is None + assert call_count == 1 + + +def test_apply_xlwings_set_font_size() -> None: + class _FakeFontApi: + Size: float = 0.0 + + class _FakeRangeApi: + Font: _FakeFontApi + + def __init__(self) -> None: + self.Font = _FakeFontApi() + + class _FakeRange: + value: object | None = None + formula: str | None = None + api: object + + def __init__(self, api: _FakeRangeApi) -> None: + self.api = api + + class _FakeSheet: + name = "Sheet1" + api = object() + + def __init__(self) -> None: + self.range_api = _FakeRangeApi() + self.last_ref = "" + + def range(self, cell: str) -> legacy_runner.XlwingsRangeProtocol: + self.last_ref = cell + return _FakeRange(self.range_api) + + sheet = _FakeSheet() + op = PatchOp(op="set_font_size", sheet="Sheet1", range="A1:B2", font_size=13.0) + diff = legacy_runner._apply_xlwings_set_font_size(sheet, op, index=0) + + assert sheet.last_ref == "A1:B2" + assert sheet.range_api.Font.Size == 13.0 + assert diff.after is not None + assert diff.after.value == "font_size=13.0" + + +def test_run_patch_error_includes_hint_for_known_set_fill_color_mistake( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + + def _raise_known_error( + sheet: legacy_runner.OpenpyxlWorksheetProtocol, + op: PatchOp, + index: int, + ) -> tuple[legacy_runner.PatchDiffItem, PatchOp | None]: + raise ValueError("set_fill_color does not accept color.") + + monkeypatch.setattr( + legacy_runner, + "_apply_openpyxl_set_fill_color", + _raise_known_error, + ) + result = legacy_runner.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="set_fill_color", + sheet="Sheet1", + cell="A1", + fill_color="#112233", + ) + ], + on_conflict="rename", + backend="openpyxl", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is not None + assert result.error.hint is not None + assert "fill_color" in result.error.hint + assert result.error.expected_fields + assert result.error.example_op is not None diff --git a/tests/mcp/patch/test_models_internal_coverage.py b/tests/mcp/patch/test_models_internal_coverage.py new file mode 100644 index 0000000..b527f06 --- /dev/null +++ b/tests/mcp/patch/test_models_internal_coverage.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + +from pydantic import ValidationError +import pytest + +from exstruct.mcp.patch import internal, models + +PatchOpFactory = Callable[..., object] + + +@pytest.mark.parametrize( + ("op_factory", "request_factory", "make_factory"), + [ + (models.PatchOp, models.PatchRequest, models.MakeRequest), + (internal.PatchOp, internal.PatchRequest, internal.MakeRequest), + ], + ids=["models", "internal"], +) # type: ignore[misc] +@pytest.mark.parametrize( + ("payload", "message"), + [ + ( + {"op": "set_value", "sheet": "Sheet1", "cell": "1A", "value": "x"}, + "Invalid cell reference", + ), + ( + {"op": "set_dimensions", "sheet": "Sheet1"}, + "set_dimensions requires rows and/or columns", + ), + ( + {"op": "set_dimensions", "sheet": "Sheet1", "rows": [1]}, + "set_dimensions requires row_height when rows is provided", + ), + ( + { + "op": "set_alignment", + "sheet": "Sheet1", + "cell": "A1", + }, + "set_alignment requires at least one of", + ), + ( + { + "op": "set_style", + "sheet": "Sheet1", + "cell": "A1", + "font_size": 0, + }, + "set_style font_size must be > 0", + ), + ( + { + "op": "draw_grid_border", + "sheet": "Sheet1", + "base_cell": "A1", + "row_count": 0, + "col_count": 1, + }, + "draw_grid_border requires row_count >= 1 and col_count >= 1", + ), + ( + { + "op": "set_formula", + "sheet": "Sheet1", + "cell": "A1", + "formula": "SUM(1,1)", + }, + "set_formula requires formula starting with '='", + ), + ( + { + "op": "set_fill_color", + "sheet": "Sheet1", + "cell": "A1", + "fill_color": "red", + }, + "Invalid fill_color format", + ), + ( + { + "op": "set_font_color", + "sheet": "Sheet1", + "cell": "A1", + "color": "#112233", + "fill_color": "#FFFFFF", + }, + "set_font_color does not accept fill_color", + ), + ( + { + "op": "auto_fit_columns", + "sheet": "Sheet1", + "min_width": 10, + "max_width": 5, + }, + "auto_fit_columns requires min_width <= max_width", + ), + ( + { + "op": "set_dimensions", + "sheet": "Sheet1", + "columns": [0], + "column_width": 18, + }, + "columns numeric values must be positive", + ), + ], +) # type: ignore[misc] +def test_patch_op_validation_errors( + op_factory: PatchOpFactory, + request_factory: Callable[..., object], + make_factory: Callable[..., object], + payload: dict[str, Any], + message: str, +) -> None: + with pytest.raises(ValidationError, match=message): + op_factory(**payload) + + # Keep fixtures referenced so parametrization stays aligned across modules. + assert request_factory is not None + assert make_factory is not None + + +@pytest.mark.parametrize( + ("op_factory", "request_factory", "make_factory"), + [ + (models.PatchOp, models.PatchRequest, models.MakeRequest), + (internal.PatchOp, internal.PatchRequest, internal.MakeRequest), + ], + ids=["models", "internal"], +) # type: ignore[misc] +def test_backend_com_rejects_dry_run_and_restore_design_snapshot( + op_factory: PatchOpFactory, + request_factory: Callable[..., object], + make_factory: Callable[..., object], +) -> None: + with pytest.raises(ValidationError, match="backend='com' does not support"): + request_factory( + xlsx_path="book.xlsx", + ops=[op_factory(op="add_sheet", sheet="Data")], + dry_run=True, + backend="com", + ) + + with pytest.raises(ValidationError, match="backend='com' does not support"): + make_factory( + out_path="book.xlsx", + ops=[op_factory(op="add_sheet", sheet="Data")], + dry_run=True, + backend="com", + ) + + design_snapshot: dict[str, list[object]] = { + "borders": [], + "fonts": [], + "fills": [], + "alignments": [], + "row_dimensions": [], + "column_dimensions": [], + } + with pytest.raises( + ValidationError, + match="backend='com' does not support restore_design_snapshot operation", + ): + request_factory( + xlsx_path="book.xlsx", + ops=[ + op_factory( + op="restore_design_snapshot", + sheet="Sheet1", + design_snapshot=design_snapshot, + ) + ], + backend="com", + ) + + +def test_internal_xlwings_helpers_error_and_success_paths() -> None: + class _FakeFont: + Bold: bool = False + Size: float = 0.0 + Color: int = 0 + + class _FakeInterior: + Color: int = 0 + + class _FakeRangeApi: + Font: _FakeFont + Interior: _FakeInterior + + def __init__(self) -> None: + self.Font = _FakeFont() + self.Interior = _FakeInterior() + + class _FakeRange: + value: object | None = None + formula: str | None = None + api: _FakeRangeApi + + def __init__(self) -> None: + self.api = _FakeRangeApi() + + class _FakeSheet: + name = "Sheet1" + api = object() + + def __init__(self) -> None: + self.ranges: dict[str, _FakeRange] = {} + + def range(self, ref: str) -> _FakeRange: + self.ranges.setdefault(ref, _FakeRange()) + return self.ranges[ref] + + class _FakeSheets: + def __init__(self, initial: list[_FakeSheet]) -> None: + self._items = initial + + def __getitem__(self, index: int) -> _FakeSheet: + return self._items[index] + + def __len__(self) -> int: + return len(self._items) + + def add(self, name: str, after: _FakeSheet | None = None) -> _FakeSheet: + del after + sheet = _FakeSheet() + sheet.name = name + self._items.append(sheet) + return sheet + + class _FakeWorkbook: + def __init__(self) -> None: + self.sheets = _FakeSheets([_FakeSheet()]) + + workbook = _FakeWorkbook() + known_sheet = workbook.sheets[0] + sheet_map = {"Sheet1": known_sheet} + + add_sheet_op = internal.PatchOp(op="add_sheet", sheet="NewSheet") + diff = internal._apply_xlwings_op( + cast(internal.XlwingsWorkbookProtocol, workbook), + cast(dict[str, internal.XlwingsSheetProtocol], sheet_map), + add_sheet_op, + 0, + False, + ) + assert diff.after is not None + assert diff.after.value == "NewSheet" + assert "NewSheet" in sheet_map + + missing_sheet_op = internal.PatchOp( + op="set_value", sheet="Missing", cell="A1", value="x" + ) + with pytest.raises(ValueError, match="Sheet not found: Missing"): + internal._apply_xlwings_op( + cast(internal.XlwingsWorkbookProtocol, workbook), + cast(dict[str, internal.XlwingsSheetProtocol], sheet_map), + missing_sheet_op, + 1, + False, + ) + + bad_values_op = internal.PatchOp.model_construct( + op="set_range_values", + sheet="Sheet1", + range="A1:B2", + values=[[1], [2]], + ) + with pytest.raises(ValueError, match="values width does not match range"): + internal._apply_xlwings_set_range_values( + cast(internal.XlwingsSheetProtocol, known_sheet), + bad_values_op, + index=2, + ) + + with pytest.raises( + ValueError, match="apply_table_style is supported only on openpyxl backend" + ): + internal._apply_xlwings_apply_table_style( + internal.PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B2", + style="TableStyleMedium2", + ) + ) + + cell = known_sheet.range("A1") + with pytest.raises(ValueError, match="set_value rejects values starting with '='"): + internal._set_xlwings_cell_value( + cast(internal.XlwingsRangeProtocol, cell), + "=1+1", + auto_formula=False, + op_name="set_value", + ) + converted = internal._set_xlwings_cell_value( + cast(internal.XlwingsRangeProtocol, cell), + "=1+1", + auto_formula=True, + op_name="set_value", + ) + assert converted.kind == "formula" + assert cell.formula == "=1+1" + + +def test_internal_auto_fit_column_resolution_defaults() -> None: + class _SheetWithoutUsedRange: + pass + + assert internal._resolve_auto_fit_columns_xlwings( + cast(internal.XlwingsSheetProtocol, _SheetWithoutUsedRange()), None + ) == ["A"] + + class _LastCell: + column = 3 + + class _UsedRange: + last_cell = _LastCell() + + class _SheetWithUsedRange: + used_range = _UsedRange() + + assert internal._resolve_auto_fit_columns_xlwings( + cast(internal.XlwingsSheetProtocol, _SheetWithUsedRange()), None + ) == ["A", "B", "C"] diff --git a/tests/mcp/patch/test_normalize.py b/tests/mcp/patch/test_normalize.py new file mode 100644 index 0000000..99b379e --- /dev/null +++ b/tests/mcp/patch/test_normalize.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import pytest + +from exstruct.mcp.patch.normalize import ( + coerce_patch_ops, + resolve_top_level_sheet_for_payload, +) + + +def test_coerce_patch_ops_normalizes_aliases() -> None: + result = coerce_patch_ops( + [ + {"op": "add_sheet", "name": "Data"}, + { + "op": "set_dimensions", + "sheet": "Data", + "col": ["A", 2], + "width": 18, + "row": [1], + "height": 24, + }, + { + "op": "set_alignment", + "sheet": "Data", + "cell": "A1", + "horizontal": "center", + "vertical": "bottom", + }, + { + "op": "set_fill_color", + "sheet": "Data", + "cell": "B1", + "color": "#D9E1F2", + }, + ] + ) + assert result[0] == {"op": "add_sheet", "sheet": "Data"} + assert result[1]["columns"] == ["A", 2] + assert result[1]["column_width"] == 18 + assert result[1]["rows"] == [1] + assert result[1]["row_height"] == 24 + assert result[2]["horizontal_align"] == "center" + assert result[2]["vertical_align"] == "bottom" + assert result[3]["fill_color"] == "#D9E1F2" + + +def test_coerce_patch_ops_normalizes_draw_grid_border_range() -> None: + result = coerce_patch_ops( + [{"op": "draw_grid_border", "sheet": "Sheet1", "range": "B3:D5"}] + ) + assert result[0]["base_cell"] == "B3" + assert result[0]["row_count"] == 3 + assert result[0]["col_count"] == 3 + assert "range" not in result[0] + + +def test_resolve_top_level_sheet_for_payload() -> None: + payload = { + "sheet": "Sheet1", + "ops": [ + {"op": "set_value", "cell": "A1", "value": "x"}, + {"op": "add_sheet", "name": "Data"}, + ], + } + resolved = resolve_top_level_sheet_for_payload(payload) + assert isinstance(resolved, dict) + ops = resolved["ops"] + assert ops[0]["sheet"] == "Sheet1" + assert ops[1]["sheet"] == "Data" + + +def test_resolve_top_level_sheet_for_payload_rejects_missing_sheet() -> None: + with pytest.raises(ValueError, match="missing sheet"): + resolve_top_level_sheet_for_payload( + {"ops": [{"op": "set_value", "cell": "A1", "value": "x"}]} + ) diff --git a/tests/mcp/patch/test_ops.py b/tests/mcp/patch/test_ops.py new file mode 100644 index 0000000..23d1437 --- /dev/null +++ b/tests/mcp/patch/test_ops.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from exstruct.mcp.patch.engine.xlwings_engine import apply_xlwings_engine +from exstruct.mcp.patch.models import ( + FormulaIssue, + OpenpyxlEngineResult, + PatchDiffItem, + PatchOp, + PatchRequest, + PatchValue, +) +from exstruct.mcp.patch.ops.openpyxl_ops import apply_openpyxl_ops +from exstruct.mcp.patch.ops.xlwings_ops import apply_xlwings_ops + + +def test_coerce_model_list_accepts_valid_items_and_skips_invalid() -> None: + from exstruct.mcp.patch.ops import openpyxl_ops + + items: list[object] = [ + PatchOp(op="add_sheet", sheet="Data"), + {"op": "add_sheet", "sheet": "Data2"}, + PatchValue(kind="value", value="x"), + "invalid", + ] + + coerced = openpyxl_ops._coerce_model_list(items, PatchOp) + assert coerced == [ + PatchOp(op="add_sheet", sheet="Data"), + PatchOp(op="add_sheet", sheet="Data2"), + ] + + +def test_apply_openpyxl_ops_delegates_to_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import exstruct.mcp.patch.internal as legacy_runner + + expected: tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[str, ...], + ] = ( + ( + { + "op_index": 0, + "op": "add_sheet", + "sheet": "Data", + "cell": "A1", + "before": None, + "after": {"kind": "sheet", "value": "created"}, + "status": "applied", + }, + ), + ( + { + "op": "add_sheet", + "sheet": "UndoData", + }, + ), + ( + { + "level": "warning", + "code": "div0_error", + "sheet": "Data", + "cell": "A1", + "message": "formula warning", + }, + ), + ("warn",), + ) + + def _fake_apply_ops_openpyxl( + request: PatchRequest, + input_path: Path, + output_path: Path, + ) -> tuple[ + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[dict[str, object], ...], + tuple[str, ...], + ]: + return expected + + monkeypatch.setattr(legacy_runner, "_apply_ops_openpyxl", _fake_apply_ops_openpyxl) + result = apply_openpyxl_ops( + PatchRequest( + xlsx_path=Path("input.xlsx"), + ops=[PatchOp(op="add_sheet", sheet="Data")], + ), + Path("input.xlsx"), + Path("output.xlsx"), + ) + assert result == OpenpyxlEngineResult( + patch_diff=[ + PatchDiffItem( + op_index=0, + op="add_sheet", + sheet="Data", + cell="A1", + before=None, + after=PatchValue(kind="sheet", value="created"), + status="applied", + ) + ], + inverse_ops=[PatchOp(op="add_sheet", sheet="UndoData")], + formula_issues=[ + FormulaIssue( + level="warning", + code="div0_error", + sheet="Data", + cell="A1", + message="formula warning", + ) + ], + op_warnings=["warn"], + ) + + +def test_apply_xlwings_ops_delegates_to_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import exstruct.mcp.patch.internal as legacy_runner + + expected = ("diff",) + + def _fake_apply_ops_xlwings( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> tuple[str, ...]: + return expected + + monkeypatch.setattr(legacy_runner, "_apply_ops_xlwings", _fake_apply_ops_xlwings) + result = apply_xlwings_ops( + Path("input.xlsx"), + Path("output.xlsx"), + [PatchOp(op="add_sheet", sheet="Data")], + auto_formula=False, + ) + assert result == ["diff"] + + +def test_apply_xlwings_engine_delegates_to_ops( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import exstruct.mcp.patch.engine.xlwings_engine as engine_module + + expected: list[object] = ["ok"] + + def _fake_apply_xlwings_ops( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + assert input_path == Path("input.xlsx") + assert output_path == Path("output.xlsx") + assert ops == [PatchOp(op="add_sheet", sheet="Data")] + assert auto_formula is True + return expected + + monkeypatch.setattr(engine_module, "apply_xlwings_ops", _fake_apply_xlwings_ops) + + result = apply_xlwings_engine( + Path("input.xlsx"), + Path("output.xlsx"), + [PatchOp(op="add_sheet", sheet="Data")], + True, + ) + assert result == expected diff --git a/tests/mcp/patch/test_service.py b/tests/mcp/patch/test_service.py new file mode 100644 index 0000000..de3de4f --- /dev/null +++ b/tests/mcp/patch/test_service.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook +import pytest + +from exstruct.cli.availability import ComAvailability +from exstruct.mcp.patch import runtime as patch_runtime, service +from exstruct.mcp.patch.models import OpenpyxlEngineResult +from exstruct.mcp.patch_runner import MakeRequest, PatchOp, PatchRequest, PatchResult + + +def _create_workbook(path: Path) -> None: + """Create a minimal workbook fixture for patch tests. + + Args: + path: Target workbook path. + """ + workbook = Workbook() + sheet = workbook.active + assert sheet is not None + sheet.title = "Sheet1" + sheet["A1"] = "old" + workbook.save(path) + workbook.close() + + +def test_patch_runner_run_patch_delegates_to_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify patch_runner.run_patch delegates to patch.service.run_patch. + + Args: + monkeypatch: Pytest monkeypatch fixture. + """ + import exstruct.mcp.patch_runner as patch_runner + + expected = PatchResult(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + def _fake_run_patch( + request: PatchRequest, *, policy: object | None = None + ) -> PatchResult: + return expected + + monkeypatch.setattr(service, "run_patch", _fake_run_patch) + request = PatchRequest( + xlsx_path=Path("input.xlsx"), + ops=[PatchOp(op="add_sheet", sheet="Data")], + ) + result = patch_runner.run_patch(request) + assert result is expected + + +def test_patch_runner_run_make_delegates_to_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify patch_runner.run_make delegates to patch.service.run_make. + + Args: + monkeypatch: Pytest monkeypatch fixture. + """ + import exstruct.mcp.patch_runner as patch_runner + + expected = PatchResult(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + def _fake_run_make( + request: MakeRequest, *, policy: object | None = None + ) -> PatchResult: + return expected + + monkeypatch.setattr(service, "run_make", _fake_run_make) + request = MakeRequest(out_path=Path("output.xlsx"), ops=[]) + result = patch_runner.run_make(request) + assert result is expected + + +def test_service_run_patch_backend_auto_prefers_com( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify backend=auto uses COM when available. + + Args: + tmp_path: Temporary directory fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + calls: dict[str, bool] = {} + + monkeypatch.setattr( + patch_runtime, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + + def _fake_apply_xlwings_engine( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + calls["com"] = True + return [] + + monkeypatch.setattr(service, "apply_xlwings_engine", _fake_apply_xlwings_engine) + result = service.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + backend="auto", + ) + ) + assert result.error is None + assert result.engine == "com" + assert calls["com"] is True + + +def test_service_run_patch_backend_auto_fallbacks_to_openpyxl_on_com_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify backend=auto falls back to openpyxl when COM apply fails. + + Args: + tmp_path: Temporary directory fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + + monkeypatch.setattr( + patch_runtime, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + + def _raise_com_error( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + raise RuntimeError("boom") + + def _fake_apply_openpyxl_engine( + request: PatchRequest, + input_path: Path, + output_path: Path, + ) -> OpenpyxlEngineResult: + return OpenpyxlEngineResult() + + monkeypatch.setattr(service, "apply_xlwings_engine", _raise_com_error) + monkeypatch.setattr(service, "apply_openpyxl_engine", _fake_apply_openpyxl_engine) + result = service.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + backend="auto", + ) + ) + assert result.error is None + assert result.engine == "openpyxl" + assert any("falling back to openpyxl" in warning for warning in result.warnings) + + +def test_service_run_patch_backend_com_does_not_fallback_on_com_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify backend=com propagates COM errors without fallback. + + Args: + tmp_path: Temporary directory fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + + monkeypatch.setattr( + patch_runtime, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + + def _raise_com_error( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + raise RuntimeError("boom") + + monkeypatch.setattr(service, "apply_xlwings_engine", _raise_com_error) + with pytest.raises(RuntimeError, match=r"COM patch failed"): + service.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + backend="com", + ) + ) + + +def test_service_run_patch_backend_com_fallbacks_for_apply_table_style( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify backend=com reroutes apply_table_style to openpyxl. + + Args: + tmp_path: Temporary directory fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + + monkeypatch.setattr( + patch_runtime, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + + def _fail_if_called( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + raise AssertionError("COM backend should not be called for apply_table_style") + + def _fake_apply_openpyxl_engine( + request: PatchRequest, + input_path: Path, + output_path: Path, + ) -> OpenpyxlEngineResult: + return OpenpyxlEngineResult() + + monkeypatch.setattr(service, "apply_xlwings_engine", _fail_if_called) + monkeypatch.setattr(service, "apply_openpyxl_engine", _fake_apply_openpyxl_engine) + result = service.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B3", + style="TableStyleMedium2", + table_name="SalesTable", + ) + ], + on_conflict="rename", + backend="com", + ) + ) + assert result.error is None + assert result.engine == "openpyxl" + assert any( + "does not support apply_table_style" in warning for warning in result.warnings + ) + + +def test_service_run_patch_backend_auto_fallbacks_for_apply_table_style( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify backend=auto reroutes apply_table_style to openpyxl. + + Args: + tmp_path: Temporary directory fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + + monkeypatch.setattr( + patch_runtime, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + + def _fail_if_called( + input_path: Path, + output_path: Path, + ops: list[PatchOp], + auto_formula: bool, + ) -> list[object]: + raise AssertionError("COM backend should not be called for apply_table_style") + + def _fake_apply_openpyxl_engine( + request: PatchRequest, + input_path: Path, + output_path: Path, + ) -> OpenpyxlEngineResult: + return OpenpyxlEngineResult() + + monkeypatch.setattr(service, "apply_xlwings_engine", _fail_if_called) + monkeypatch.setattr(service, "apply_openpyxl_engine", _fake_apply_openpyxl_engine) + result = service.run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B3", + style="TableStyleMedium2", + table_name="SalesTable", + ) + ], + on_conflict="rename", + backend="auto", + ) + ) + assert result.error is None + assert result.engine == "openpyxl" + assert any( + "does not support apply_table_style" in warning for warning in result.warnings + ) diff --git a/tests/mcp/shared/test_a1.py b/tests/mcp/shared/test_a1.py new file mode 100644 index 0000000..2ae78fb --- /dev/null +++ b/tests/mcp/shared/test_a1.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +from exstruct.mcp.shared.a1 import ( + column_index_to_label, + column_label_to_index, + parse_range_geometry, + range_cell_count, + split_a1, +) + + +def test_column_roundtrip() -> None: + assert column_label_to_index("A") == 1 + assert column_label_to_index("AA") == 27 + assert column_index_to_label(1) == "A" + assert column_index_to_label(27) == "AA" + + +def test_split_a1() -> None: + assert split_a1("b12") == ("B", 12) + + +def test_range_cell_count() -> None: + assert range_cell_count("A1:C3") == 9 + assert range_cell_count("C3:A1") == 9 + + +def test_parse_range_geometry() -> None: + base, rows, cols = parse_range_geometry("D6:B4") + assert base == "B4" + assert rows == 3 + assert cols == 3 + + +def test_split_a1_rejects_invalid() -> None: + with pytest.raises(ValueError, match="Invalid cell reference"): + split_a1("1A") diff --git a/tests/mcp/shared/test_output_path.py b/tests/mcp/shared/test_output_path.py new file mode 100644 index 0000000..6fc5ce9 --- /dev/null +++ b/tests/mcp/shared/test_output_path.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pathlib import Path + +from exstruct.mcp.shared.output_path import ( + apply_conflict_policy, + next_available_path, + normalize_output_name, +) + + +def test_normalize_output_name_same_stem() -> None: + input_path = Path("report.xlsx") + assert ( + normalize_output_name( + input_path, + out_name=None, + default_suffix=".json", + default_name_builder="same_stem", + ) + == "report.json" + ) + + +def test_normalize_output_name_patched() -> None: + input_path = Path("report.xlsx") + assert ( + normalize_output_name( + input_path, + out_name=None, + default_suffix=".xlsx", + default_name_builder="patched", + ) + == "report_patched.xlsx" + ) + + +def test_apply_conflict_policy_rename(tmp_path: Path) -> None: + target = tmp_path / "result.json" + target.write_text("x", encoding="utf-8") + resolved, warning, skipped = apply_conflict_policy(target, "rename") + assert resolved.name == "result_1.json" + assert warning == "Output exists; renamed to: result_1.json" + assert skipped is False + + +def test_next_available_path_no_conflict(tmp_path: Path) -> None: + target = tmp_path / "result.json" + assert next_available_path(target) == target diff --git a/tests/mcp/test_chunk_reader.py b/tests/mcp/test_chunk_reader.py index 4741cd0..8b0cf9f 100644 --- a/tests/mcp/test_chunk_reader.py +++ b/tests/mcp/test_chunk_reader.py @@ -275,3 +275,75 @@ def test_read_json_chunk_warns_on_too_small_max_bytes(tmp_path: Path) -> None: request = ReadJsonChunkRequest(out_path=out, sheet="Sheet1", max_bytes=max_bytes) result = read_json_chunk(request) assert any("max_bytes too small" in warning for warning in result.warnings) + + +def test_read_json_chunk_missing_output_file_raises(tmp_path: Path) -> None: + request = ReadJsonChunkRequest(out_path=tmp_path / "missing.json", sheet="Sheet1") + with pytest.raises(FileNotFoundError, match="Output file not found"): + read_json_chunk(request) + + +def test_read_json_chunk_single_sheet_without_explicit_sheet(tmp_path: Path) -> None: + data = { + "book_name": "book", + "sheets": {"Only": {"rows": [{"r": 1, "c": {"0": "A"}}]}}, + } + out = tmp_path / "out.json" + _write_json(out, data) + request = ReadJsonChunkRequest( + out_path=out, + sheet=None, + filter=ReadJsonChunkFilter(rows=(1, 1)), + max_bytes=10_000, + ) + result = read_json_chunk(request) + payload = json.loads(result.chunk) + assert payload["sheet_name"] == "Only" + + +def test_chunk_reader_private_helpers_cover_boundary_paths() -> None: + rows = [{"r": 1, "c": {"0": "A"}}, {"r": 2, "c": "not-dict"}] + filtered = chunk_reader._filter_cols(rows, (1, 1), warnings=[]) + assert filtered[1]["c"] == "not-dict" + assert chunk_reader._extract_rows({"rows": "invalid"}) == [] + assert chunk_reader._col_in_range("bad-key", 0, 3) is False + assert chunk_reader._row_index({"r": "x"}) == -1 + assert chunk_reader._parse_col_index("A1") is None + + +def test_build_sheet_chunk_returns_next_cursor_when_second_row_overflows() -> None: + data = {"book_name": "book"} + sheet_name = "Sheet1" + rows = [ + {"r": 1, "c": {"0": "x" * 80}}, + {"r": 2, "c": {"0": "y" * 80}}, + ] + sheet_data = {"rows": rows} + + first_payload = { + "book_name": "book", + "sheet_name": sheet_name, + "sheet": {"rows": [rows[0]]}, + } + second_payload = { + "book_name": "book", + "sheet_name": sheet_name, + "sheet": {"rows": rows}, + } + first_size = len(chunk_reader._serialize_json(first_payload).encode("utf-8")) + second_size = len(chunk_reader._serialize_json(second_payload).encode("utf-8")) + max_bytes = first_size + 1 + assert second_size > max_bytes + + chunk, next_cursor, warnings = chunk_reader._build_sheet_chunk( + data, + sheet_name, + sheet_data, + rows, + cursor=None, + max_bytes=max_bytes, + ) + payload = json.loads(chunk) + assert len(payload["sheet"]["rows"]) == 1 + assert next_cursor == "1" + assert warnings == [] diff --git a/tests/mcp/test_make_runner.py b/tests/mcp/test_make_runner.py new file mode 100644 index 0000000..debb2cd --- /dev/null +++ b/tests/mcp/test_make_runner.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook, load_workbook +import pytest + +from exstruct.cli.availability import ComAvailability +from exstruct.mcp import patch_runner +from exstruct.mcp.io import PathPolicy +from exstruct.mcp.patch_runner import MakeRequest, PatchOp, run_make + + +def _disable_com(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + patch_runner, + "get_com_availability", + lambda: ComAvailability(available=False, reason="test"), + ) + + +def _write_workbook(path: Path, value: str) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "Sheet1" + sheet["A1"] = value + workbook.save(path) + workbook.close() + + +def test_run_make_creates_xlsx_with_sheet1( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + result = run_make( + MakeRequest(out_path=out_path, ops=[]), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert result.engine == "openpyxl" + workbook = load_workbook(result.out_path) + try: + assert "Sheet1" in workbook.sheetnames + finally: + workbook.close() + + +def test_run_make_applies_ops(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + result = run_make( + MakeRequest( + out_path=out_path, + ops=[ + PatchOp(op="add_sheet", sheet="Data"), + PatchOp(op="set_value", sheet="Data", cell="A1", value="ok"), + ], + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + assert workbook["Data"]["A1"].value == "ok" + finally: + workbook.close() + + +def test_run_make_uses_top_level_sheet_as_initial_sheet_when_no_matching_add_sheet( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + result = run_make( + MakeRequest( + out_path=out_path, + sheet="Data", + ops=[PatchOp(op="set_value", sheet="Data", cell="A1", value="ok")], + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + assert "Data" in workbook.sheetnames + assert workbook["Data"]["A1"].value == "ok" + finally: + workbook.close() + + +def test_run_make_keeps_sheet1_when_matching_add_sheet_exists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + result = run_make( + MakeRequest( + out_path=out_path, + sheet="Data", + ops=[ + PatchOp(op="add_sheet", sheet="Data"), + PatchOp(op="set_value", sheet="Data", cell="A1", value="ok"), + ], + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + assert "Sheet1" in workbook.sheetnames + assert "Data" in workbook.sheetnames + assert workbook["Data"]["A1"].value == "ok" + finally: + workbook.close() + + +def test_run_make_keeps_sheet1_when_add_sheet_differs_only_by_case( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + result = run_make( + MakeRequest( + out_path=out_path, + sheet="Data", + ops=[ + PatchOp(op="add_sheet", sheet="data"), + PatchOp(op="set_value", sheet="data", cell="A1", value="ok"), + ], + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + assert "Sheet1" in workbook.sheetnames + assert "data" in workbook.sheetnames + assert workbook["data"]["A1"].value == "ok" + finally: + workbook.close() + + +def test_run_make_conflict_overwrite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + _write_workbook(out_path, "old") + result = run_make( + MakeRequest( + out_path=out_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="overwrite", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(out_path) + try: + assert workbook["Sheet1"]["A1"].value == "new" + finally: + workbook.close() + + +def test_run_make_conflict_skip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + _write_workbook(out_path, "old") + result = run_make( + MakeRequest( + out_path=out_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="skip", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.patch_diff == [] + workbook = load_workbook(out_path) + try: + assert workbook["Sheet1"]["A1"].value == "old" + finally: + workbook.close() + + +def test_run_make_conflict_rename( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + out_path = tmp_path / "book.xlsx" + _write_workbook(out_path, "old") + result = run_make( + MakeRequest( + out_path=out_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert Path(result.out_path) != out_path + assert Path(result.out_path).exists() + + +def test_run_make_rejects_path_outside_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + root = tmp_path / "root" + root.mkdir() + with pytest.raises(ValueError): + run_make( + MakeRequest(out_path=tmp_path / "book.xlsx"), + policy=PathPolicy(root=root), + ) + + +def test_run_make_resolves_relative_out_path_from_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + root = tmp_path / "root" + root.mkdir() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + result = run_make( + MakeRequest(out_path=Path("outputs/book.xlsx")), + policy=PathPolicy(root=root), + ) + assert result.error is None + assert Path(result.out_path) == (root / "outputs" / "book.xlsx").resolve() + + +def test_run_make_rejects_xls_when_com_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + with pytest.raises(ValueError, match=r"requires Windows Excel COM"): + run_make( + MakeRequest(out_path=tmp_path / "book.xls"), + policy=PathPolicy(root=tmp_path), + ) + + +def test_run_make_rejects_xls_with_openpyxl_backend(tmp_path: Path) -> None: + with pytest.raises(ValueError, match=r"backend='openpyxl' cannot edit \.xls files"): + run_make( + MakeRequest(out_path=tmp_path / "book.xls", backend="openpyxl"), + policy=PathPolicy(root=tmp_path), + ) + + +def test_run_make_rejects_xls_with_dry_run_flags(tmp_path: Path) -> None: + with pytest.raises( + ValueError, + match=r"\.xls creation does not support dry_run, return_inverse_ops, or preflight_formula_check", + ): + run_make( + MakeRequest(out_path=tmp_path / "book.xls", dry_run=True), + policy=PathPolicy(root=tmp_path), + ) diff --git a/tests/mcp/test_patch_runner.py b/tests/mcp/test_patch_runner.py index 860ec8c..5a5fa25 100644 --- a/tests/mcp/test_patch_runner.py +++ b/tests/mcp/test_patch_runner.py @@ -3,6 +3,7 @@ from pathlib import Path from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Font from pydantic import ValidationError import pytest @@ -22,6 +23,21 @@ def _create_workbook(path: Path) -> None: workbook.close() +def _seed_table_source(path: Path) -> None: + workbook = load_workbook(path) + try: + sheet = workbook["Sheet1"] + sheet["A1"] = "Name" + sheet["B1"] = "Amount" + sheet["A2"] = "A" + sheet["B2"] = 100 + sheet["A3"] = "B" + sheet["B3"] = 200 + workbook.save(path) + finally: + workbook.close() + + def _disable_com(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( patch_runner, @@ -55,6 +71,94 @@ def test_run_patch_set_value_and_formula( workbook.close() assert len(result.patch_diff) == 2 assert result.patch_diff[0].after is not None + assert result.engine == "openpyxl" + + +def test_run_patch_backend_auto_uses_openpyxl_when_com_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + backend="auto", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert result.engine == "openpyxl" + + +def test_run_patch_backend_com_requires_com_available( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + with pytest.raises(ValueError, match=r"backend='com' requires"): + run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="new")], + on_conflict="rename", + backend="com", + ), + policy=PathPolicy(root=tmp_path), + ) + + +def test_run_patch_backend_openpyxl_rejects_xls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + patch_runner, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + input_path = tmp_path / "book.xls" + input_path.write_text("dummy", encoding="utf-8") + with pytest.raises(ValueError, match=r"backend='openpyxl' cannot edit \.xls"): + run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="add_sheet", sheet="Sheet2")], + on_conflict="rename", + backend="openpyxl", + ), + policy=PathPolicy(root=tmp_path), + ) + + +def test_patch_request_backend_com_rejects_dry_run() -> None: + with pytest.raises(ValidationError, match=r"backend='com' does not support"): + PatchRequest( + xlsx_path=Path("book.xlsx"), + ops=[PatchOp(op="add_sheet", sheet="S2")], + dry_run=True, + backend="com", + ) + + +def test_patch_request_backend_com_rejects_restore_design_snapshot() -> None: + with pytest.raises( + ValidationError, + match=r"backend='com' does not support restore_design_snapshot operation", + ): + PatchRequest( + xlsx_path=Path("book.xlsx"), + ops=[ + PatchOp( + op="restore_design_snapshot", + sheet="Sheet1", + design_snapshot=patch_runner.DesignSnapshot(), + ) + ], + backend="com", + ) def test_run_patch_add_sheet_and_set_value( @@ -530,3 +634,872 @@ def test_patch_op_add_sheet_rejects_unrelated_fields() -> None: def test_patch_op_set_value_rejects_expected() -> None: with pytest.raises(ValidationError, match="set_value does not accept expected"): PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="x", expected="old") + + +def test_run_patch_draw_grid_border_and_inverse_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + ops = [ + PatchOp( + op="draw_grid_border", + sheet="Sheet1", + base_cell="A1", + row_count=2, + col_count=2, + ) + ] + request = PatchRequest( + xlsx_path=input_path, + ops=ops, + on_conflict="rename", + return_inverse_ops=True, + ) + result = run_patch(request, policy=PathPolicy(root=tmp_path)) + assert result.error is None + assert len(result.inverse_ops) == 1 + workbook = load_workbook(result.out_path) + try: + assert workbook["Sheet1"]["A1"].border.top.style == "thin" + finally: + workbook.close() + + restored = run_patch( + PatchRequest( + xlsx_path=Path(result.out_path), + ops=result.inverse_ops, + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + restored_book = load_workbook(restored.out_path) + try: + assert restored_book["Sheet1"]["A1"].border.top.style is None + finally: + restored_book.close() + + +def test_run_patch_set_bold_and_fill_color( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + ops = [ + PatchOp(op="set_bold", sheet="Sheet1", range="A1:B1"), + PatchOp(op="set_fill_color", sheet="Sheet1", cell="A1", fill_color="#112233"), + ] + request = PatchRequest(xlsx_path=input_path, ops=ops, on_conflict="rename") + result = run_patch(request, policy=PathPolicy(root=tmp_path)) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + cell = workbook["Sheet1"]["A1"] + assert cell.font.bold is True + assert cell.fill.fill_type == "solid" + assert cell.fill.start_color.rgb == "FF112233" + finally: + workbook.close() + + +def test_run_patch_set_font_size_cell_and_range( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + ops = [ + PatchOp(op="set_font_size", sheet="Sheet1", cell="A1", font_size=14.5), + PatchOp(op="set_font_size", sheet="Sheet1", range="A1:B1", font_size=16.0), + ] + request = PatchRequest(xlsx_path=input_path, ops=ops, on_conflict="rename") + result = run_patch(request, policy=PathPolicy(root=tmp_path)) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + sheet = workbook["Sheet1"] + assert sheet["A1"].font.size == 16.0 + assert sheet["B1"].font.size == 16.0 + finally: + workbook.close() + + +def test_run_patch_set_font_size_preserves_other_font_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"]["A1"].font = Font( + name="Calibri", bold=True, italic=True, size=11.0 + ) + workbook.save(input_path) + finally: + workbook.close() + + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp(op="set_font_size", sheet="Sheet1", cell="A1", font_size=18.0) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + out_book = load_workbook(result.out_path) + try: + font = out_book["Sheet1"]["A1"].font + assert font.name == "Calibri" + assert font.bold is True + assert font.italic is True + assert font.size == 18.0 + finally: + out_book.close() + + +def test_run_patch_set_dimensions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + ops = [ + PatchOp( + op="set_dimensions", + sheet="Sheet1", + rows=[1, 2], + row_height=24.5, + columns=["A", 2], + column_width=18.0, + ) + ] + request = PatchRequest(xlsx_path=input_path, ops=ops, on_conflict="rename") + result = run_patch(request, policy=PathPolicy(root=tmp_path)) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + sheet = workbook["Sheet1"] + assert sheet.row_dimensions[1].height == 24.5 + assert sheet.column_dimensions["A"].width == 18.0 + assert sheet.column_dimensions["B"].width == 18.0 + finally: + workbook.close() + after_value = result.patch_diff[0].after + assert after_value is not None + assert isinstance(after_value.value, str) + assert "columns=A, B (2)" in after_value.value + + +def test_run_patch_auto_fit_columns_with_bounds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + sheet = workbook["Sheet1"] + sheet["A1"] = "short" + sheet["B1"] = "this is a much longer sample text" + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="auto_fit_columns", + sheet="Sheet1", + min_width=8, + max_width=20, + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + sheet = workbook["Sheet1"] + width_a = sheet.column_dimensions["A"].width + width_b = sheet.column_dimensions["B"].width + assert width_a is not None + assert width_b is not None + assert 8 <= width_a <= 20 + assert 8 <= width_b <= 20 + assert width_b >= width_a + finally: + workbook.close() + + +def test_run_patch_auto_fit_columns_accepts_mixed_column_identifiers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="auto_fit_columns", + sheet="Sheet1", + columns=["A", 2], + min_width=9, + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + workbook = load_workbook(result.out_path) + try: + sheet = workbook["Sheet1"] + assert sheet.column_dimensions["A"].width is not None + assert sheet.column_dimensions["B"].width is not None + assert sheet.column_dimensions["A"].width >= 9 + assert sheet.column_dimensions["B"].width >= 9 + finally: + workbook.close() + + +def test_patch_op_auto_fit_columns_rejects_invalid_bounds() -> None: + with pytest.raises( + ValidationError, match="auto_fit_columns requires min_width <= max_width" + ): + PatchOp( + op="auto_fit_columns", + sheet="Sheet1", + min_width=20, + max_width=10, + ) + + +def test_run_patch_warns_when_ops_exceed_soft_threshold( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + ops = [ + PatchOp(op="set_value", sheet="Sheet1", cell="A1", value=f"v{i}") + for i in range(201) + ] + result = run_patch( + PatchRequest(xlsx_path=input_path, ops=ops, on_conflict="rename"), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert any("Recommended maximum is 200" in warning for warning in result.warnings) + + +def test_patch_op_set_bold_rejects_cell_and_range() -> None: + with pytest.raises( + ValidationError, match="set_bold requires exactly one of cell or range" + ): + PatchOp(op="set_bold", sheet="Sheet1", cell="A1", range="A1:A1") + + +def test_patch_op_set_fill_color_rejects_invalid_color() -> None: + with pytest.raises( + ValidationError, + match="Invalid fill_color format. Use 'RRGGBB', 'AARRGGBB', '#RRGGBB', or '#AARRGGBB'", + ): + PatchOp(op="set_fill_color", sheet="Sheet1", cell="A1", fill_color="red") + + +def test_patch_op_normalizes_hex_inputs() -> None: + fill_op = PatchOp( + op="set_fill_color", + sheet="Sheet1", + cell="A1", + fill_color="1f4e79", + ) + font_op = PatchOp( + op="set_font_color", + sheet="Sheet1", + cell="A1", + color="cc336699", + ) + assert fill_op.fill_color == "#1F4E79" + assert font_op.color == "#CC336699" + + +def test_patch_op_set_font_color_rejects_fill_color() -> None: + with pytest.raises( + ValidationError, match="set_font_color does not accept fill_color" + ): + PatchOp( + op="set_font_color", + sheet="Sheet1", + cell="A1", + color="#112233", + fill_color="#445566", + ) + + +def test_patch_op_set_fill_color_rejects_color() -> None: + with pytest.raises(ValidationError, match="set_fill_color does not accept color"): + PatchOp( + op="set_fill_color", + sheet="Sheet1", + cell="A1", + fill_color="#112233", + color="#445566", + ) + + +def test_patch_op_set_style_requires_at_least_one_style_field() -> None: + with pytest.raises(ValidationError, match="set_style requires at least one style"): + PatchOp(op="set_style", sheet="Sheet1", cell="A1") + + +def test_patch_op_set_style_rejects_cell_and_range() -> None: + with pytest.raises( + ValidationError, match="set_style requires exactly one of cell or range" + ): + PatchOp( + op="set_style", + sheet="Sheet1", + cell="A1", + range="A1:B1", + bold=True, + ) + + +def test_patch_op_apply_table_style_requires_style() -> None: + with pytest.raises(ValidationError, match="apply_table_style requires style"): + PatchOp(op="apply_table_style", sheet="Sheet1", range="A1:B3") + + +def test_patch_op_apply_table_style_rejects_cell() -> None: + with pytest.raises( + ValidationError, match="apply_table_style does not accept cell or base_cell" + ): + PatchOp( + op="apply_table_style", + sheet="Sheet1", + cell="A1", + range="A1:B3", + style="TableStyleMedium2", + ) + + +def test_run_patch_set_font_color( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp(op="set_font_color", sheet="Sheet1", cell="A1", color="112233") + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + out_book = load_workbook(result.out_path) + try: + color = out_book["Sheet1"]["A1"].font.color + assert color is not None + assert str(getattr(color, "rgb", "")).upper() == "FF112233" + finally: + out_book.close() + + +def test_patch_op_set_font_size_rejects_non_positive() -> None: + with pytest.raises(ValidationError, match="set_font_size font_size must be > 0"): + PatchOp(op="set_font_size", sheet="Sheet1", cell="A1", font_size=0) + + +def test_patch_op_set_font_size_rejects_cell_and_range() -> None: + with pytest.raises( + ValidationError, match="set_font_size requires exactly one of cell or range" + ): + PatchOp( + op="set_font_size", + sheet="Sheet1", + cell="A1", + range="A1:A1", + font_size=12, + ) + + +def test_patch_op_set_font_size_requires_target() -> None: + with pytest.raises( + ValidationError, match="set_font_size requires exactly one of cell or range" + ): + PatchOp(op="set_font_size", sheet="Sheet1", font_size=12) + + +def test_patch_op_set_dimensions_requires_dimension_pair() -> None: + with pytest.raises( + ValidationError, + match="set_dimensions requires column_width when columns is provided", + ): + PatchOp(op="set_dimensions", sheet="Sheet1", columns=["A"]) + + +def test_patch_op_style_target_limit() -> None: + with pytest.raises(ValidationError, match="target exceeds max cells"): + PatchOp(op="set_bold", sheet="Sheet1", range="A1:Z500") + + +def test_run_patch_rejects_design_op_for_xls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + patch_runner, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + input_path = tmp_path / "book.xls" + input_path.write_text("dummy", encoding="utf-8") + request = PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="set_bold", sheet="Sheet1", cell="A1")], + on_conflict="rename", + ) + with pytest.raises( + ValueError, match=r"Design operations are not supported for \.xls files" + ): + run_patch(request, policy=PathPolicy(root=tmp_path)) + + +def test_patch_op_merge_cells_requires_multi_cell_range() -> None: + with pytest.raises( + ValidationError, match="merge_cells requires a multi-cell range" + ): + PatchOp(op="merge_cells", sheet="Sheet1", range="A1:A1") + + +def test_run_patch_merge_cells_and_inverse_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + request = PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="merge_cells", sheet="Sheet1", range="A1:B1")], + on_conflict="rename", + return_inverse_ops=True, + ) + result = run_patch(request, policy=PathPolicy(root=tmp_path)) + assert result.error is None + assert len(result.inverse_ops) == 1 + + workbook = load_workbook(result.out_path) + try: + ranges = [str(item) for item in workbook["Sheet1"].merged_cells.ranges] + assert ranges == ["A1:B1"] + finally: + workbook.close() + + restored = run_patch( + PatchRequest( + xlsx_path=Path(result.out_path), + ops=result.inverse_ops, + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + restored_book = load_workbook(restored.out_path) + try: + assert list(restored_book["Sheet1"].merged_cells.ranges) == [] + finally: + restored_book.close() + + +def test_run_patch_merge_cells_rejects_overlap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"].merge_cells("A1:B1") + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="merge_cells", sheet="Sheet1", range="B1:C1")], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is not None + assert "overlaps existing merged ranges" in result.error.message + + +def test_run_patch_merge_cells_warns_on_value_loss( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"]["B1"] = "drop-me" + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="merge_cells", sheet="Sheet1", range="A1:B1")], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert any( + "may clear non-top-left values" in warning for warning in result.warnings + ) + + +def test_run_patch_unmerge_cells_for_intersections( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + sheet = workbook["Sheet1"] + sheet["C1"] = "v" + sheet["D1"] = "w" + sheet.merge_cells("A1:B1") + sheet.merge_cells("C1:D1") + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[PatchOp(op="unmerge_cells", sheet="Sheet1", range="B1:C1")], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + out_book = load_workbook(result.out_path) + try: + assert list(out_book["Sheet1"].merged_cells.ranges) == [] + finally: + out_book.close() + + +def test_run_patch_set_alignment_preserves_unspecified_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"]["A1"].alignment = Alignment(vertical="top", wrap_text=True) + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="set_alignment", + sheet="Sheet1", + cell="A1", + horizontal_align="center", + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + out_book = load_workbook(result.out_path) + try: + alignment = out_book["Sheet1"]["A1"].alignment + assert alignment.horizontal == "center" + assert alignment.vertical == "top" + assert alignment.wrap_text is True + finally: + out_book.close() + + +def test_run_patch_set_alignment_inverse_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"]["A1"].alignment = Alignment( + horizontal="left", vertical="bottom", wrap_text=True + ) + workbook.save(input_path) + finally: + workbook.close() + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="set_alignment", + sheet="Sheet1", + range="A1:B1", + horizontal_align="center", + vertical_align="center", + wrap_text=False, + ) + ], + on_conflict="rename", + return_inverse_ops=True, + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert len(result.inverse_ops) == 1 + restored = run_patch( + PatchRequest( + xlsx_path=Path(result.out_path), + ops=result.inverse_ops, + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + restored_book = load_workbook(restored.out_path) + try: + alignment = restored_book["Sheet1"]["A1"].alignment + assert alignment.horizontal == "left" + assert alignment.vertical == "bottom" + assert alignment.wrap_text is True + finally: + restored_book.close() + + +def test_run_patch_set_style_and_inverse_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + workbook = load_workbook(input_path) + try: + workbook["Sheet1"]["A1"].alignment = Alignment(horizontal="left") + workbook.save(input_path) + finally: + workbook.close() + + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="set_style", + sheet="Sheet1", + range="A1:B1", + bold=True, + color="#112233", + fill_color="#D9E1F2", + horizontal_align="center", + wrap_text=True, + ) + ], + on_conflict="rename", + return_inverse_ops=True, + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + assert len(result.inverse_ops) == 1 + + out_book = load_workbook(result.out_path) + try: + cell = out_book["Sheet1"]["A1"] + assert cell.font.bold is True + assert str(getattr(cell.font.color, "rgb", "")).upper() == "FF112233" + assert cell.fill.fill_type == "solid" + assert str(getattr(cell.fill.start_color, "rgb", "")).upper() == "FFD9E1F2" + assert cell.alignment.horizontal == "center" + assert cell.alignment.wrap_text is True + finally: + out_book.close() + + restored = run_patch( + PatchRequest( + xlsx_path=Path(result.out_path), + ops=result.inverse_ops, + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + restored_book = load_workbook(restored.out_path) + try: + restored_cell = restored_book["Sheet1"]["A1"] + assert restored_cell.font.bold is False + assert restored_cell.fill.fill_type is None + assert restored_cell.alignment.horizontal == "left" + finally: + restored_book.close() + + +def test_run_patch_apply_table_style( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + _seed_table_source(input_path) + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B3", + style="TableStyleMedium2", + table_name="SalesTable", + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is None + out_book = load_workbook(result.out_path) + try: + sheet = out_book["Sheet1"] + table = sheet.tables["SalesTable"] + assert table.ref == "A1:B3" + style_info = table.tableStyleInfo + assert style_info is not None + assert style_info.name == "TableStyleMedium2" + finally: + out_book.close() + + +def test_run_patch_apply_table_style_rejects_duplicate_table_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + _seed_table_source(input_path) + result = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B3", + style="TableStyleMedium2", + table_name="SalesTable", + ), + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="D1:E3", + style="TableStyleMedium2", + table_name="SalesTable", + ), + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert result.error is not None + assert "Table name already exists" in result.error.message + + +def test_run_patch_apply_table_style_rejects_intersection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_com(monkeypatch) + input_path = tmp_path / "book.xlsx" + _create_workbook(input_path) + _seed_table_source(input_path) + first = run_patch( + PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="A1:B3", + style="TableStyleMedium2", + table_name="SalesTable", + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert first.error is None + second = run_patch( + PatchRequest( + xlsx_path=Path(first.out_path), + ops=[ + PatchOp( + op="apply_table_style", + sheet="Sheet1", + range="B2:C4", + style="TableStyleMedium2", + table_name="SalesTable2", + ) + ], + on_conflict="rename", + ), + policy=PathPolicy(root=tmp_path), + ) + assert second.error is not None + assert "intersects existing table" in second.error.message + + +def test_run_patch_rejects_alignment_design_op_for_xls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + patch_runner, + "get_com_availability", + lambda: ComAvailability(available=True, reason=None), + ) + input_path = tmp_path / "book.xls" + input_path.write_text("dummy", encoding="utf-8") + request = PatchRequest( + xlsx_path=input_path, + ops=[ + PatchOp( + op="set_alignment", + sheet="Sheet1", + cell="A1", + horizontal_align="center", + ) + ], + on_conflict="rename", + ) + with pytest.raises( + ValueError, match=r"Design operations are not supported for \.xls files" + ): + run_patch(request, policy=PathPolicy(root=tmp_path)) diff --git a/tests/mcp/test_path_policy.py b/tests/mcp/test_path_policy.py index a1f610a..b62f176 100644 --- a/tests/mcp/test_path_policy.py +++ b/tests/mcp/test_path_policy.py @@ -17,8 +17,9 @@ def test_path_policy_allows_within_root(tmp_path: Path) -> None: def test_path_policy_denies_outside_root(tmp_path: Path) -> None: policy = PathPolicy(root=tmp_path) outside = tmp_path.parent / "outside.txt" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="resolved=.*root=.*example_relative") as exc: policy.ensure_allowed(outside) + assert "exstruct_get_runtime_info" in str(exc.value) def test_path_policy_denies_glob(tmp_path: Path) -> None: @@ -28,3 +29,9 @@ def test_path_policy_denies_glob(tmp_path: Path) -> None: denied.write_text("x", encoding="utf-8") with pytest.raises(ValueError): policy.ensure_allowed(denied) + + +def test_path_policy_resolves_relative_from_root(tmp_path: Path) -> None: + policy = PathPolicy(root=tmp_path) + allowed = policy.ensure_allowed(Path("outputs/book.xlsx")) + assert allowed == (tmp_path / "outputs" / "book.xlsx").resolve() diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index 6064f4a..4936867 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -11,9 +11,14 @@ from exstruct.mcp import server from exstruct.mcp.extract_runner import OnConflictPolicy from exstruct.mcp.io import PathPolicy +from exstruct.mcp.patch import normalize as patch_normalize from exstruct.mcp.tools import ( + DescribeOpToolOutput, ExtractToolInput, ExtractToolOutput, + ListOpsToolOutput, + MakeToolInput, + MakeToolOutput, PatchToolInput, PatchToolOutput, ReadCellsToolInput, @@ -24,6 +29,7 @@ ReadJsonChunkToolOutput, ReadRangeToolInput, ReadRangeToolOutput, + RuntimeInfoToolOutput, ValidateInputToolInput, ValidateInputToolOutput, ) @@ -59,11 +65,13 @@ def test_parse_args_defaults(tmp_path: Path) -> None: assert config.log_level == "INFO" assert config.log_file is None assert config.on_conflict == "overwrite" + assert config.artifact_bridge_dir is None assert config.warmup is False def test_parse_args_with_options(tmp_path: Path) -> None: log_file = tmp_path / "log.txt" + bridge_dir = tmp_path / "bridge" config = server._parse_args( [ "--root", @@ -78,6 +86,8 @@ def test_parse_args_with_options(tmp_path: Path) -> None: str(log_file), "--on-conflict", "rename", + "--artifact-bridge-dir", + str(bridge_dir), "--warmup", ] ) @@ -85,6 +95,7 @@ def test_parse_args_with_options(tmp_path: Path) -> None: assert config.log_level == "DEBUG" assert config.log_file == log_file assert config.on_conflict == "rename" + assert config.artifact_bridge_dir == bridge_dir assert config.warmup is True @@ -130,6 +141,109 @@ def test_coerce_patch_ops_rejects_non_object_json_value() -> None: server._coerce_patch_ops(['["not","object"]']) +def test_coerce_patch_ops_normalizes_aliases() -> None: + result = server._coerce_patch_ops( + [ + {"op": "add_sheet", "name": "Data"}, + { + "op": "set_dimensions", + "sheet": "Data", + "col": ["A", 2], + "width": 18, + "row": [1], + "height": 24, + }, + { + "op": "set_alignment", + "sheet": "Data", + "cell": "A1", + "horizontal": "center", + "vertical": "bottom", + }, + { + "op": "set_fill_color", + "sheet": "Data", + "cell": "B1", + "color": "#D9E1F2", + }, + ] + ) + assert result[0] == {"op": "add_sheet", "sheet": "Data"} + assert result[1]["columns"] == ["A", 2] + assert result[1]["column_width"] == 18 + assert result[1]["rows"] == [1] + assert result[1]["row_height"] == 24 + assert "col" not in result[1] + assert "row" not in result[1] + assert "width" not in result[1] + assert "height" not in result[1] + assert result[2]["horizontal_align"] == "center" + assert result[2]["vertical_align"] == "bottom" + assert "horizontal" not in result[2] + assert "vertical" not in result[2] + assert result[3]["fill_color"] == "#D9E1F2" + assert "color" not in result[3] + + +def test_coerce_patch_ops_rejects_conflicting_aliases() -> None: + with pytest.raises(ValueError, match="conflicting fields"): + server._coerce_patch_ops( + [ + { + "op": "set_dimensions", + "sheet": "Sheet1", + "columns": ["A"], + "col": ["B"], + } + ] + ) + + +def test_coerce_patch_ops_rejects_conflicting_alignment_aliases() -> None: + with pytest.raises(ValueError, match="conflicting fields"): + server._coerce_patch_ops( + [ + { + "op": "set_alignment", + "sheet": "Sheet1", + "cell": "A1", + "horizontal": "left", + "horizontal_align": "center", + } + ] + ) + + +def test_coerce_patch_ops_normalizes_draw_grid_border_range() -> None: + result = server._coerce_patch_ops( + [ + { + "op": "draw_grid_border", + "sheet": "Sheet1", + "range": "B3:D5", + } + ] + ) + assert result[0]["base_cell"] == "B3" + assert result[0]["row_count"] == 3 + assert result[0]["col_count"] == 3 + assert "range" not in result[0] + + +def test_coerce_patch_ops_rejects_mixed_draw_grid_border_inputs() -> None: + with pytest.raises(ValueError, match="does not allow mixing 'range'"): + server._coerce_patch_ops( + [ + { + "op": "draw_grid_border", + "sheet": "Sheet1", + "range": "A1:C3", + "base_cell": "A1", + } + ] + ) + + def test_register_tools_uses_default_on_conflict( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -169,7 +283,16 @@ def fake_run_patch_tool( on_conflict: OnConflictPolicy, ) -> PatchToolOutput: calls["patch"] = (payload, policy, on_conflict) - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + def fake_run_make_tool( + payload: MakeToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> MakeToolOutput: + calls["make"] = (payload, policy, on_conflict) + return MakeToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -180,6 +303,7 @@ async def fake_run_sync(func: Callable[[], object]) -> object: ) monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(server, "run_make_tool", fake_run_make_tool) monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) server._register_tools(app, policy, default_on_conflict="rename") @@ -204,6 +328,12 @@ async def fake_run_sync(func: Callable[[], object]) -> object: patch_tool, {"xlsx_path": "in.xlsx", "ops": [{"op": "add_sheet", "sheet": "New"}]}, ) + make_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_make"]) + anyio.run( + _call_async, + make_tool, + {"out_path": "out.xlsx", "ops": [{"op": "add_sheet", "sheet": "New"}]}, + ) assert calls["extract"][2] == "rename" chunk_call = cast(tuple[ReadJsonChunkToolInput, PathPolicy], calls["chunk"]) @@ -215,6 +345,87 @@ async def fake_run_sync(func: Callable[[], object]) -> object: assert patch_call[0].dry_run is False assert patch_call[0].return_inverse_ops is False assert patch_call[0].preflight_formula_check is False + assert patch_call[0].backend == "auto" + assert patch_call[0].mirror_artifact is False + assert calls["make"][2] == "rename" + make_call = cast(tuple[MakeToolInput, PathPolicy, OnConflictPolicy], calls["make"]) + assert make_call[0].ops[0].op == "add_sheet" + assert make_call[0].mirror_artifact is False + + +def test_register_tools_returns_runtime_info(tmp_path: Path) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + server._register_tools(app, policy, default_on_conflict="overwrite") + + runtime_tool = cast( + Callable[..., Awaitable[object]], + app.tools["exstruct_get_runtime_info"], + ) + result = cast(RuntimeInfoToolOutput, anyio.run(_call_async, runtime_tool, {})) + assert Path(result.root) == tmp_path.resolve() + assert Path(result.cwd) == Path.cwd().resolve() + assert result.platform != "" + assert result.path_examples.relative == "outputs/book.xlsx" + assert Path(result.path_examples.absolute) == ( + tmp_path.resolve() / "outputs" / "book.xlsx" + ) + + +def test_register_tools_returns_ops_schema_tools(tmp_path: Path) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + server._register_tools(app, policy, default_on_conflict="overwrite") + + list_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_list_ops"]) + list_result = cast(ListOpsToolOutput, anyio.run(_call_async, list_tool, {})) + listed_ops = [item.op for item in list_result.ops] + assert "set_value" in listed_ops + assert "set_style" in listed_ops + assert "apply_table_style" in listed_ops + assert "auto_fit_columns" in listed_ops + + describe_tool = cast( + Callable[..., Awaitable[object]], + app.tools["exstruct_describe_op"], + ) + describe_result = cast( + DescribeOpToolOutput, + anyio.run(_call_async, describe_tool, {"op": "set_fill_color"}), + ) + assert describe_result.required == ["sheet (or top-level sheet)", "fill_color"] + assert describe_result.aliases == {"color": "fill_color"} + + +def test_register_tools_describe_op_rejects_unknown_op(tmp_path: Path) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + server._register_tools(app, policy, default_on_conflict="overwrite") + + describe_tool = cast( + Callable[..., Awaitable[object]], + app.tools["exstruct_describe_op"], + ) + with pytest.raises(ValueError, match="Unknown op"): + anyio.run(_call_async, describe_tool, {"op": "unknown_op"}) + + +def test_patch_tool_doc_includes_op_mini_schema(tmp_path: Path) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + server._register_tools(app, policy, default_on_conflict="overwrite") + + patch_tool = app.tools["exstruct_patch"] + patch_doc = patch_tool.__doc__ + assert patch_doc is not None + assert "Mini op schema" in patch_doc + assert "set_fill_color" in patch_doc + assert "required: sheet (or top-level sheet), fill_color" in patch_doc + assert "aliases: color -> fill_color" in patch_doc + assert ( + "Sheet resolution: non-add_sheet ops allow top-level sheet fallback" + in patch_doc + ) def test_register_tools_passes_read_tool_arguments( @@ -283,7 +494,7 @@ def fake_run_patch_tool( policy: PathPolicy, on_conflict: OnConflictPolicy, ) -> PatchToolOutput: - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -372,7 +583,7 @@ def fake_run_patch_tool( on_conflict: OnConflictPolicy, ) -> PatchToolOutput: calls["patch"] = (payload, policy, on_conflict) - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -392,7 +603,10 @@ async def fake_run_sync(func: Callable[[], object]) -> object: patch_tool, { "xlsx_path": "in.xlsx", - "ops": ['{"op":"add_sheet","sheet":"New"}'], + "ops": [ + '{"op":"add_sheet","sheet":"New"}', + '{"op":"set_bold","sheet":"New","cell":"A1"}', + ], }, ) patch_call = cast( @@ -400,6 +614,357 @@ async def fake_run_sync(func: Callable[[], object]) -> object: ) assert patch_call[0].ops[0].op == "add_sheet" assert patch_call[0].ops[0].sheet == "New" + assert patch_call[0].ops[1].op == "set_bold" + assert patch_call[0].ops[1].cell == "A1" + + +def test_register_tools_applies_top_level_sheet_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + calls: dict[str, tuple[object, ...]] = {} + + def fake_run_extract_tool( + payload: ExtractToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> ExtractToolOutput: + return ExtractToolOutput(out_path="out.json") + + def fake_run_read_json_chunk_tool( + payload: ReadJsonChunkToolInput, + *, + policy: PathPolicy, + ) -> ReadJsonChunkToolOutput: + return ReadJsonChunkToolOutput(chunk="{}") + + def fake_run_validate_input_tool( + payload: ValidateInputToolInput, + *, + policy: PathPolicy, + ) -> ValidateInputToolOutput: + return ValidateInputToolOutput(is_readable=True) + + def fake_run_patch_tool( + payload: PatchToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> PatchToolOutput: + calls["patch"] = (payload, policy, on_conflict) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + async def fake_run_sync(func: Callable[[], object]) -> object: + return func() + + monkeypatch.setattr(server, "run_extract_tool", fake_run_extract_tool) + monkeypatch.setattr( + server, "run_read_json_chunk_tool", fake_run_read_json_chunk_tool + ) + monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) + monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) + + server._register_tools(app, policy, default_on_conflict="overwrite") + patch_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_patch"]) + anyio.run( + _call_async, + patch_tool, + { + "xlsx_path": "in.xlsx", + "sheet": "Sheet1", + "ops": [{"op": "set_value", "cell": "A1", "value": "x"}], + }, + ) + patch_call = cast( + tuple[PatchToolInput, PathPolicy, OnConflictPolicy], calls["patch"] + ) + assert patch_call[0].sheet == "Sheet1" + assert patch_call[0].ops[0].sheet == "Sheet1" + + +def test_register_tools_accepts_make_ops_json_strings( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + calls: dict[str, tuple[object, ...]] = {} + + def fake_run_extract_tool( + payload: ExtractToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> ExtractToolOutput: + return ExtractToolOutput(out_path="out.json") + + def fake_run_read_json_chunk_tool( + payload: ReadJsonChunkToolInput, + *, + policy: PathPolicy, + ) -> ReadJsonChunkToolOutput: + return ReadJsonChunkToolOutput(chunk="{}") + + def fake_run_validate_input_tool( + payload: ValidateInputToolInput, + *, + policy: PathPolicy, + ) -> ValidateInputToolOutput: + return ValidateInputToolOutput(is_readable=True) + + def fake_run_patch_tool( + payload: PatchToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> PatchToolOutput: + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + def fake_run_make_tool( + payload: MakeToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> MakeToolOutput: + calls["make"] = (payload, policy, on_conflict) + return MakeToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + async def fake_run_sync(func: Callable[[], object]) -> object: + return func() + + monkeypatch.setattr(server, "run_extract_tool", fake_run_extract_tool) + monkeypatch.setattr( + server, "run_read_json_chunk_tool", fake_run_read_json_chunk_tool + ) + monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) + monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(server, "run_make_tool", fake_run_make_tool) + monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) + + server._register_tools(app, policy, default_on_conflict="overwrite") + make_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_make"]) + anyio.run( + _call_async, + make_tool, + { + "out_path": "out.xlsx", + "ops": [ + '{"op":"add_sheet","sheet":"New"}', + '{"op":"set_value","sheet":"New","cell":"A1","value":"x"}', + ], + }, + ) + make_call = cast(tuple[MakeToolInput, PathPolicy, OnConflictPolicy], calls["make"]) + assert make_call[0].ops[0].op == "add_sheet" + assert make_call[0].ops[1].op == "set_value" + assert make_call[0].ops[1].value == "x" + + +def test_register_tools_accepts_merge_and_alignment_json_strings( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + calls: dict[str, tuple[object, ...]] = {} + + def fake_run_extract_tool( + payload: ExtractToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> ExtractToolOutput: + return ExtractToolOutput(out_path="out.json") + + def fake_run_read_json_chunk_tool( + payload: ReadJsonChunkToolInput, + *, + policy: PathPolicy, + ) -> ReadJsonChunkToolOutput: + return ReadJsonChunkToolOutput(chunk="{}") + + def fake_run_validate_input_tool( + payload: ValidateInputToolInput, + *, + policy: PathPolicy, + ) -> ValidateInputToolOutput: + return ValidateInputToolOutput(is_readable=True) + + def fake_run_patch_tool( + payload: PatchToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> PatchToolOutput: + calls["patch"] = (payload, policy, on_conflict) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + async def fake_run_sync(func: Callable[[], object]) -> object: + return func() + + monkeypatch.setattr(server, "run_extract_tool", fake_run_extract_tool) + monkeypatch.setattr( + server, "run_read_json_chunk_tool", fake_run_read_json_chunk_tool + ) + monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) + monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) + + server._register_tools(app, policy, default_on_conflict="overwrite") + patch_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_patch"]) + anyio.run( + _call_async, + patch_tool, + { + "xlsx_path": "in.xlsx", + "ops": [ + '{"op":"merge_cells","sheet":"Sheet1","range":"A1:B1"}', + '{"op":"set_alignment","sheet":"Sheet1","range":"A1:B1","horizontal_align":"center","wrap_text":true}', + ], + }, + ) + + patch_call = cast( + tuple[PatchToolInput, PathPolicy, OnConflictPolicy], calls["patch"] + ) + assert patch_call[0].ops[0].op == "merge_cells" + assert patch_call[0].ops[1].op == "set_alignment" + assert patch_call[0].ops[1].wrap_text is True + + +def test_register_tools_accepts_set_font_size_json_string( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + calls: dict[str, tuple[object, ...]] = {} + + def fake_run_extract_tool( + payload: ExtractToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> ExtractToolOutput: + return ExtractToolOutput(out_path="out.json") + + def fake_run_read_json_chunk_tool( + payload: ReadJsonChunkToolInput, + *, + policy: PathPolicy, + ) -> ReadJsonChunkToolOutput: + return ReadJsonChunkToolOutput(chunk="{}") + + def fake_run_validate_input_tool( + payload: ValidateInputToolInput, + *, + policy: PathPolicy, + ) -> ValidateInputToolOutput: + return ValidateInputToolOutput(is_readable=True) + + def fake_run_patch_tool( + payload: PatchToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> PatchToolOutput: + calls["patch"] = (payload, policy, on_conflict) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + async def fake_run_sync(func: Callable[[], object]) -> object: + return func() + + monkeypatch.setattr(server, "run_extract_tool", fake_run_extract_tool) + monkeypatch.setattr( + server, "run_read_json_chunk_tool", fake_run_read_json_chunk_tool + ) + monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) + monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) + + server._register_tools(app, policy, default_on_conflict="overwrite") + patch_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_patch"]) + anyio.run( + _call_async, + patch_tool, + { + "xlsx_path": "in.xlsx", + "ops": [ + '{"op":"set_font_size","sheet":"Sheet1","range":"A1:B1","font_size":15.5}', + ], + }, + ) + + patch_call = cast( + tuple[PatchToolInput, PathPolicy, OnConflictPolicy], calls["patch"] + ) + assert patch_call[0].ops[0].op == "set_font_size" + assert patch_call[0].ops[0].font_size == 15.5 + + +def test_register_tools_returns_patch_warnings( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + app = DummyApp() + policy = PathPolicy(root=tmp_path) + + def fake_run_extract_tool( + payload: ExtractToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> ExtractToolOutput: + return ExtractToolOutput(out_path="out.json") + + def fake_run_read_json_chunk_tool( + payload: ReadJsonChunkToolInput, + *, + policy: PathPolicy, + ) -> ReadJsonChunkToolOutput: + return ReadJsonChunkToolOutput(chunk="{}") + + def fake_run_validate_input_tool( + payload: ValidateInputToolInput, + *, + policy: PathPolicy, + ) -> ValidateInputToolOutput: + return ValidateInputToolOutput(is_readable=True) + + def fake_run_patch_tool( + payload: PatchToolInput, + *, + policy: PathPolicy, + on_conflict: OnConflictPolicy, + ) -> PatchToolOutput: + return PatchToolOutput( + out_path="out.xlsx", + patch_diff=[], + warnings=["merge_cells may clear non-top-left values"], + engine="openpyxl", + ) + + async def fake_run_sync(func: Callable[[], object]) -> object: + return func() + + monkeypatch.setattr(server, "run_extract_tool", fake_run_extract_tool) + monkeypatch.setattr( + server, "run_read_json_chunk_tool", fake_run_read_json_chunk_tool + ) + monkeypatch.setattr(server, "run_validate_input_tool", fake_run_validate_input_tool) + monkeypatch.setattr(server, "run_patch_tool", fake_run_patch_tool) + monkeypatch.setattr(anyio.to_thread, "run_sync", fake_run_sync) + + server._register_tools(app, policy, default_on_conflict="overwrite") + patch_tool = cast(Callable[..., Awaitable[object]], app.tools["exstruct_patch"]) + result = cast( + PatchToolOutput, + anyio.run( + _call_async, + patch_tool, + {"xlsx_path": "in.xlsx", "ops": [{"op": "add_sheet", "sheet": "New"}]}, + ), + ) + assert result.warnings == ["merge_cells may clear non-top-left values"] def test_register_tools_rejects_invalid_patch_ops_json_strings( @@ -436,7 +1001,7 @@ def fake_run_patch_tool( policy: PathPolicy, on_conflict: OnConflictPolicy, ) -> PatchToolOutput: - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -498,7 +1063,7 @@ def fake_run_patch_tool( on_conflict: OnConflictPolicy, ) -> PatchToolOutput: calls["patch"] = (payload, policy, on_conflict) - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -558,7 +1123,7 @@ def fake_run_patch_tool( on_conflict: OnConflictPolicy, ) -> PatchToolOutput: calls["patch"] = (payload, policy, on_conflict) - return PatchToolOutput(out_path="out.xlsx", patch_diff=[]) + return PatchToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") async def fake_run_sync(func: Callable[[], object]) -> object: return func() @@ -582,6 +1147,7 @@ async def fake_run_sync(func: Callable[[], object]) -> object: "dry_run": True, "return_inverse_ops": True, "preflight_formula_check": True, + "backend": "openpyxl", }, ) patch_call = cast( @@ -590,6 +1156,7 @@ async def fake_run_sync(func: Callable[[], object]) -> object: assert patch_call[0].dry_run is True assert patch_call[0].return_inverse_ops is True assert patch_call[0].preflight_formula_check is True + assert patch_call[0].backend == "openpyxl" def test_run_server_sets_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -602,9 +1169,15 @@ class _App: def run(self) -> None: created["ran"] = True - def fake_create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> _App: + def fake_create_app( + policy: PathPolicy, + *, + on_conflict: OnConflictPolicy, + artifact_bridge_dir: Path | None = None, + ) -> _App: created["policy"] = policy created["on_conflict"] = on_conflict + created["artifact_bridge_dir"] = artifact_bridge_dir return _App() monkeypatch.setattr(server, "_import_mcp", fake_import) @@ -614,6 +1187,7 @@ def fake_create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> _Ap assert created["imported"] is True assert created["ran"] is True assert created["on_conflict"] == "overwrite" + assert created["artifact_bridge_dir"] is None def test_configure_logging_with_file( @@ -643,3 +1217,51 @@ def _record(name: str) -> object: server._warmup_exstruct() assert "exstruct.core.cells" in calls assert "exstruct.core.integrate" in calls + + +def test_patch_normalize_aliases_covers_dimension_alias_paths() -> None: + op = { + "op": "set_dimensions", + "sheet": "Data", + "row": [1], + "height": 20, + "col": ["A", 2], + "width": 18, + } + normalized = patch_normalize.normalize_patch_op_aliases(op, index=0) + assert normalized["rows"] == [1] + assert normalized["row_height"] == 20 + assert normalized["columns"] == ["A", 2] + assert normalized["column_width"] == 18 + assert "row" not in normalized + assert "col" not in normalized + assert "height" not in normalized + assert "width" not in normalized + + +def test_patch_normalize_draw_grid_border_range_rejects_non_string() -> None: + op_data: dict[str, object] = {"op": "draw_grid_border", "range": 123} + with pytest.raises(ValueError, match="range must be a string A1 range"): + patch_normalize.normalize_draw_grid_border_range(op_data, index=2) + + +def test_patch_normalize_draw_grid_border_range_normalizes_reverse_range() -> None: + op_data: dict[str, object] = {"op": "draw_grid_border", "range": "C3:A1"} + patch_normalize.normalize_draw_grid_border_range(op_data, index=0) + assert op_data["base_cell"] == "A1" + assert op_data["row_count"] == 3 + assert op_data["col_count"] == 3 + assert "range" not in op_data + + +def test_patch_normalize_draw_grid_border_range_rejects_invalid_shape() -> None: + op_data: dict[str, object] = {"op": "draw_grid_border", "range": "A1"} + with pytest.raises(ValueError, match="draw_grid_border range must be like"): + patch_normalize.normalize_draw_grid_border_range(op_data, index=1) + + +def test_parse_patch_op_json_and_error_message_helpers() -> None: + parsed = server._parse_patch_op_json('{"op":"add_sheet","sheet":"S1"}', index=0) + assert parsed == {"op": "add_sheet", "sheet": "S1"} + message = server._build_patch_op_error_message(5, "bad input") + assert message.startswith("Invalid patch operation at ops[5]: bad input") diff --git a/tests/mcp/test_sheet_reader.py b/tests/mcp/test_sheet_reader.py index f176ac9..5af3208 100644 --- a/tests/mcp/test_sheet_reader.py +++ b/tests/mcp/test_sheet_reader.py @@ -159,3 +159,89 @@ def test_read_cells_requires_sheet_for_multi_sheet_payload(tmp_path: Path) -> No request = ReadCellsRequest(out_path=out, addresses=["A1"]) with pytest.raises(ValueError, match=r"Available sheets: A, B"): read_cells(request) + + +def test_read_range_excludes_empty_cells_when_include_empty_false( + tmp_path: Path, +) -> None: + data = {"book_name": "book", "sheets": {"Data": {"rows": []}}} + out = tmp_path / "out.json" + _write_json(out, data) + + request = ReadRangeRequest( + out_path=out, + sheet="Data", + range="A1:B1", + include_empty=False, + ) + result = read_range(request) + assert result.cells == [] + + +def test_read_range_rejects_missing_output_file(tmp_path: Path) -> None: + request = ReadRangeRequest( + out_path=tmp_path / "missing.json", sheet="Data", range="A1" + ) + with pytest.raises(FileNotFoundError, match="Output file not found"): + read_range(request) + + +def test_sheet_reader_private_helpers_cover_invalid_payload_paths() -> None: + from exstruct.mcp import sheet_reader + + with pytest.raises(ValueError, match="expected object at root"): + sheet_reader._parse_json("[]") + + with pytest.raises(ValueError, match="sheets is not a mapping"): + sheet_reader._select_sheet({"sheets": []}, None) + with pytest.raises(ValueError, match="sheet payload is not an object"): + sheet_reader._select_sheet({"sheets": {"Only": []}}, None) + with pytest.raises(ValueError, match="Invalid A1 range"): + sheet_reader._parse_range("B2:A1") + + +def test_sheet_reader_private_parsers_and_normalizers() -> None: + from exstruct.mcp import sheet_reader + + assert sheet_reader._build_value_map({"rows": "not-list"}) == {} + value_map = sheet_reader._build_value_map( + { + "rows": [ + {"r": 1, "c": {"0": "v0", "AA": "vaa", "-1": "skip"}}, + {"r": 0, "c": {"0": "skip"}}, + {"r": 2, "c": ["skip"]}, + ] + } + ) + assert value_map[(1, 1)] == "v0" + assert value_map[(1, 27)] == "vaa" + + formula_map, has_formula = sheet_reader._build_formula_map( + { + "formulas_map": { + 1: [[1, 0]], + "=OK": [[1, 0], [0, 0], [1], "bad"], + } + } + ) + assert has_formula is True + assert formula_map == {(1, 1): "=OK"} + + assert sheet_reader._parse_formula_position("bad") is None + assert sheet_reader._parse_formula_position([1]) is None + assert sheet_reader._parse_formula_position([1, "x"]) is None + assert sheet_reader._parse_formula_position([0, 0]) is None + assert sheet_reader._parse_formula_position([1, -1]) is None + + assert sheet_reader._parse_col_key("-1") is None + assert sheet_reader._parse_col_key("3") == 4 + assert sheet_reader._parse_col_key("AB") == 28 + assert sheet_reader._parse_col_key("A1") is None + + with pytest.raises(ValueError, match="Invalid column index"): + sheet_reader._col_to_alpha(0) + with pytest.raises(ValueError, match="Invalid column label"): + sheet_reader._alpha_to_col("A!") + + assert sheet_reader._normalize_scalar({"a": 1}) == "{'a': 1}" + assert sheet_reader._as_optional_str(10) == "10" diff --git a/tests/mcp/test_tool_models.py b/tests/mcp/test_tool_models.py index 9606961..3aa3a64 100644 --- a/tests/mcp/test_tool_models.py +++ b/tests/mcp/test_tool_models.py @@ -4,12 +4,19 @@ import pytest from exstruct.mcp.tools import ( + DescribeOpToolInput, + DescribeOpToolOutput, ExtractToolInput, + ListOpsToolOutput, + MakeToolInput, + MakeToolOutput, PatchToolInput, + PatchToolOutput, ReadCellsToolInput, ReadFormulasToolInput, ReadJsonChunkToolInput, ReadRangeToolInput, + RuntimeInfoToolOutput, ) @@ -37,6 +44,235 @@ def test_patch_tool_input_defaults() -> None: assert payload.dry_run is False assert payload.return_inverse_ops is False assert payload.preflight_formula_check is False + assert payload.backend == "auto" + assert payload.mirror_artifact is False + assert payload.sheet is None + + +def test_make_tool_input_defaults() -> None: + payload = MakeToolInput(out_path="output.xlsx") + assert payload.ops == [] + assert payload.on_conflict is None + assert payload.dry_run is False + assert payload.return_inverse_ops is False + assert payload.preflight_formula_check is False + assert payload.backend == "auto" + assert payload.mirror_artifact is False + assert payload.sheet is None + + +def test_patch_tool_input_applies_top_level_sheet_fallback() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + sheet="Sheet1", + ops=[{"op": "set_value", "cell": "A1", "value": "x"}], + ) + assert payload.ops[0].sheet == "Sheet1" + + +def test_patch_tool_input_prioritizes_op_sheet_over_top_level() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + sheet="Sheet1", + ops=[{"op": "set_value", "sheet": "Data", "cell": "A1", "value": "x"}], + ) + assert payload.ops[0].sheet == "Data" + + +def test_patch_tool_input_rejects_add_sheet_without_explicit_sheet() -> None: + with pytest.raises(ValidationError, match="add_sheet\\) is missing sheet"): + PatchToolInput( + xlsx_path="input.xlsx", + sheet="Sheet1", + ops=[{"op": "add_sheet"}], + ) + + +def test_patch_tool_input_accepts_add_sheet_name_alias() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[{"op": "add_sheet", "name": "Data"}], + ) + assert payload.ops[0].sheet == "Data" + + +def test_patch_tool_input_rejects_unresolved_sheet_for_non_add_sheet() -> None: + with pytest.raises(ValidationError, match="missing sheet"): + PatchToolInput( + xlsx_path="input.xlsx", + ops=[{"op": "set_value", "cell": "A1", "value": "x"}], + ) + + +def test_make_tool_input_applies_top_level_sheet_fallback() -> None: + payload = MakeToolInput( + out_path="output.xlsx", + sheet="Sheet1", + ops=[{"op": "set_value", "cell": "A1", "value": "x"}], + ) + assert payload.ops[0].sheet == "Sheet1" + + +def test_make_tool_input_accepts_add_sheet_name_alias() -> None: + payload = MakeToolInput( + out_path="output.xlsx", + ops=[{"op": "add_sheet", "name": "Data"}], + ) + assert payload.ops[0].sheet == "Data" + + +def test_patch_and_make_tool_output_defaults() -> None: + patch_output = PatchToolOutput( + out_path="out.xlsx", patch_diff=[], engine="openpyxl" + ) + make_output = MakeToolOutput(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + assert patch_output.mirrored_out_path is None + assert make_output.mirrored_out_path is None + + +def test_patch_tool_input_accepts_design_ops() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "set_dimensions", + "sheet": "Sheet1", + "rows": [1, 2], + "row_height": 20, + "columns": ["A", 2], + "column_width": 18, + } + ], + ) + assert payload.ops[0].op == "set_dimensions" + + +def test_patch_tool_input_accepts_merge_and_alignment_ops() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + {"op": "merge_cells", "sheet": "Sheet1", "range": "A1:B1"}, + { + "op": "set_alignment", + "sheet": "Sheet1", + "range": "A1:B1", + "horizontal_align": "center", + "vertical_align": "center", + "wrap_text": True, + }, + ], + ) + assert payload.ops[0].op == "merge_cells" + assert payload.ops[1].op == "set_alignment" + + +def test_patch_tool_input_accepts_set_style_op() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "set_style", + "sheet": "Sheet1", + "range": "A1:B1", + "bold": True, + "fill_color": "d9e1f2", + "horizontal_align": "center", + } + ], + ) + assert payload.ops[0].op == "set_style" + assert payload.ops[0].fill_color == "#D9E1F2" + + +def test_patch_tool_input_accepts_apply_table_style_op() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "apply_table_style", + "sheet": "Sheet1", + "range": "A1:B3", + "style": "TableStyleMedium2", + "table_name": "SalesTable", + } + ], + ) + assert payload.ops[0].op == "apply_table_style" + assert payload.ops[0].style == "TableStyleMedium2" + assert payload.ops[0].table_name == "SalesTable" + + +def test_patch_tool_input_accepts_auto_fit_columns_op() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "auto_fit_columns", + "sheet": "Sheet1", + "columns": ["A", 2], + "min_width": 8, + "max_width": 40, + } + ], + ) + assert payload.ops[0].op == "auto_fit_columns" + assert payload.ops[0].columns == ["A", 2] + assert payload.ops[0].min_width == 8 + assert payload.ops[0].max_width == 40 + + +def test_patch_tool_input_accepts_set_font_size_op() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "set_font_size", + "sheet": "Sheet1", + "cell": "A1", + "font_size": 14, + } + ], + ) + assert payload.ops[0].op == "set_font_size" + + +def test_patch_tool_input_accepts_set_font_color_op() -> None: + payload = PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "set_font_color", + "sheet": "Sheet1", + "range": "A1:B1", + "color": "1f4e79", + } + ], + ) + assert payload.ops[0].op == "set_font_color" + assert payload.ops[0].color == "#1F4E79" + + +def test_patch_tool_input_rejects_invalid_horizontal_align() -> None: + with pytest.raises(ValidationError): + PatchToolInput( + xlsx_path="input.xlsx", + ops=[ + { + "op": "set_alignment", + "sheet": "Sheet1", + "cell": "A1", + "horizontal_align": "middle", + } + ], + ) + + +def test_patch_tool_input_rejects_alignment_without_target_fields() -> None: + with pytest.raises(ValidationError): + PatchToolInput( + xlsx_path="input.xlsx", + ops=[{"op": "set_alignment", "sheet": "Sheet1", "cell": "A1"}], + ) def test_read_range_tool_input_defaults() -> None: @@ -62,3 +298,35 @@ def test_read_formulas_tool_input_defaults() -> None: assert payload.sheet is None assert payload.range is None assert payload.include_values is False + + +def test_runtime_info_tool_output_model() -> None: + payload = RuntimeInfoToolOutput( + root="C:\\data", + cwd="C:\\workspace", + platform="win32", + path_examples={ + "relative": "outputs/book.xlsx", + "absolute": "C:\\data\\outputs\\book.xlsx", + }, + ) + assert payload.path_examples.relative == "outputs/book.xlsx" + + +def test_describe_op_tool_input_accepts_op_name() -> None: + payload = DescribeOpToolInput(op="set_fill_color") + assert payload.op == "set_fill_color" + + +def test_list_ops_tool_output_defaults() -> None: + payload = ListOpsToolOutput() + assert payload.ops == [] + + +def test_describe_op_tool_output_defaults() -> None: + payload = DescribeOpToolOutput(op="set_value") + assert payload.required == [] + assert payload.optional == [] + assert payload.constraints == [] + assert payload.example == {} + assert payload.aliases == {} diff --git a/tests/mcp/test_tools_handlers.py b/tests/mcp/test_tools_handlers.py index d06effd..f18c271 100644 --- a/tests/mcp/test_tools_handlers.py +++ b/tests/mcp/test_tools_handlers.py @@ -11,7 +11,7 @@ ReadJsonChunkResult, ) from exstruct.mcp.extract_runner import ExtractRequest, ExtractResult -from exstruct.mcp.patch_runner import PatchRequest, PatchResult +from exstruct.mcp.patch_runner import MakeRequest, PatchRequest, PatchResult from exstruct.mcp.sheet_reader import ( ReadCellsRequest, ReadCellsResult, @@ -176,11 +176,12 @@ def _fake_run_patch( request: PatchRequest, *, policy: object | None = None ) -> PatchResult: captured["request"] = request - return PatchResult(out_path="out.xlsx", patch_diff=[]) + return PatchResult(out_path="out.xlsx", patch_diff=[], engine="openpyxl") monkeypatch.setattr(tools, "run_patch", _fake_run_patch) payload = tools.PatchToolInput( xlsx_path="input.xlsx", + sheet="Sheet1", ops=[{"op": "add_sheet", "sheet": "New"}], dry_run=True, return_inverse_ops=True, @@ -194,3 +195,128 @@ def _fake_run_patch( assert request.dry_run is True assert request.return_inverse_ops is True assert request.preflight_formula_check is True + assert request.backend == "auto" + assert request.sheet == "Sheet1" + + +def test_run_patch_tool_mirrors_artifact_when_enabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "out.xlsx" + source.write_text("dummy", encoding="utf-8") + + def _fake_run_patch( + request: PatchRequest, *, policy: object | None = None + ) -> PatchResult: + return PatchResult(out_path=str(source), patch_diff=[], engine="openpyxl") + + monkeypatch.setattr(tools, "run_patch", _fake_run_patch) + bridge_dir = tmp_path / "bridge" + payload = tools.PatchToolInput( + xlsx_path="input.xlsx", + ops=[{"op": "add_sheet", "sheet": "New"}], + mirror_artifact=True, + ) + result = tools.run_patch_tool(payload, artifact_bridge_dir=bridge_dir) + assert result.mirrored_out_path is not None + assert Path(result.mirrored_out_path).exists() + assert result.warnings == [] + + +def test_run_make_tool_warns_when_bridge_is_not_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fake_run_make( + request: MakeRequest, *, policy: object | None = None + ) -> PatchResult: + return PatchResult(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + monkeypatch.setattr(tools, "run_make", _fake_run_make) + payload = tools.MakeToolInput( + out_path="output.xlsx", + ops=[{"op": "add_sheet", "sheet": "New"}], + mirror_artifact=True, + ) + result = tools.run_make_tool(payload) + assert result.mirrored_out_path is None + assert any("artifact-bridge-dir" in warning for warning in result.warnings) + + +def test_run_patch_tool_warns_when_mirror_copy_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "out.xlsx" + source.write_text("dummy", encoding="utf-8") + + def _fake_run_patch( + request: PatchRequest, *, policy: object | None = None + ) -> PatchResult: + return PatchResult(out_path=str(source), patch_diff=[], engine="openpyxl") + + def _raise_copy_error(src: Path, dst: Path) -> None: + raise OSError("copy failed") + + monkeypatch.setattr(tools, "run_patch", _fake_run_patch) + monkeypatch.setattr("exstruct.mcp.tools.shutil.copy2", _raise_copy_error) + payload = tools.PatchToolInput( + xlsx_path="input.xlsx", + ops=[{"op": "add_sheet", "sheet": "New"}], + mirror_artifact=True, + ) + result = tools.run_patch_tool(payload, artifact_bridge_dir=tmp_path / "bridge") + assert result.mirrored_out_path is None + assert any("Failed to mirror artifact" in warning for warning in result.warnings) + + +def test_run_make_tool_builds_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_run_make( + request: MakeRequest, *, policy: object | None = None + ) -> PatchResult: + captured["request"] = request + return PatchResult(out_path="out.xlsx", patch_diff=[], engine="openpyxl") + + monkeypatch.setattr(tools, "run_make", _fake_run_make) + payload = tools.MakeToolInput( + out_path="output.xlsx", + sheet="Sheet1", + ops=[{"op": "add_sheet", "sheet": "New"}], + dry_run=True, + return_inverse_ops=True, + preflight_formula_check=True, + ) + tools.run_make_tool(payload, on_conflict="rename") + request = captured["request"] + assert isinstance(request, MakeRequest) + assert request.out_path == Path("output.xlsx") + assert request.on_conflict == "rename" + assert request.dry_run is True + assert request.return_inverse_ops is True + assert request.preflight_formula_check is True + assert request.backend == "auto" + assert request.sheet == "Sheet1" + + +def test_run_list_ops_tool_returns_known_ops() -> None: + result = tools.run_list_ops_tool() + op_names = [item.op for item in result.ops] + assert "set_value" in op_names + assert "set_style" in op_names + assert "apply_table_style" in op_names + assert "auto_fit_columns" in op_names + + +def test_run_describe_op_tool_returns_schema_details() -> None: + result = tools.run_describe_op_tool(tools.DescribeOpToolInput(op="set_fill_color")) + assert result.required == ["sheet (or top-level sheet)", "fill_color"] + assert "cell" in result.optional + assert result.aliases == {"color": "fill_color"} + assert result.example["op"] == "set_fill_color" + + +def test_run_describe_op_tool_rejects_unknown_op() -> None: + with pytest.raises(ValueError, match="Unknown op"): + tools.run_describe_op_tool(tools.DescribeOpToolInput(op="unknown_op")) diff --git a/uv.lock b/uv.lock index 02a67b9..90c730c 100644 --- a/uv.lock +++ b/uv.lock @@ -642,7 +642,7 @@ wheels = [ [[package]] name = "exstruct" -version = "0.4.4" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "numpy" }, @@ -3031,16 +3031,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.37.0" +version = "20.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ef/d9d4ce633df789bf3430bd81fb0d8b9d9465dfc1d1f0deb3fb62cd80f5c2/virtualenv-20.37.0.tar.gz", hash = "sha256:6f7e2064ed470aa7418874e70b6369d53b66bcd9e9fd5389763e96b6c94ccb7c", size = 5864710, upload-time = "2026-02-16T16:17:59.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/54/809199edc537dbace273495ac0884d13df26436e910a5ed4d0ec0a69806b/virtualenv-20.39.0.tar.gz", hash = "sha256:a15f0cebd00d50074fd336a169d53422436a12dfe15149efec7072cfe817df8b", size = 5869141, upload-time = "2026-02-23T18:09:13.349Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/4b/6cf85b485be7ec29db837ec2a1d8cd68bc1147b1abf23d8636c5bd65b3cc/virtualenv-20.37.0-py3-none-any.whl", hash = "sha256:5d3951c32d57232ae3569d4de4cc256c439e045135ebf43518131175d9be435d", size = 5837480, upload-time = "2026-02-16T16:17:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b4/8268da45f26f4fe84f6eae80a6ca1485ffb490a926afecff75fc48f61979/virtualenv-20.39.0-py3-none-any.whl", hash = "sha256:44888bba3775990a152ea1f73f8e5f566d49f11bbd1de61d426fd7732770043e", size = 5839121, upload-time = "2026-02-23T18:09:11.173Z" }, ] [[package]]