-
Notifications
You must be signed in to change notification settings - Fork 29
Fix patching any cell that contains a date #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| from collections.abc import Callable, Iterator | ||
| from contextlib import contextmanager | ||
| from copy import copy | ||
| from datetime import date, datetime, time | ||
| from pathlib import Path | ||
| import re | ||
| from typing import Any, Protocol, cast, runtime_checkable | ||
|
|
@@ -40,9 +41,12 @@ | |
| PatchBackend, | ||
| PatchEngine, | ||
| PatchOpType, | ||
| PatchScalar, | ||
| PatchStatus, | ||
| PatchValueKind, | ||
| PatchValueType, | ||
| VerticalAlignType, | ||
| coerce_patch_scalar, | ||
| ) | ||
|
|
||
| _ALLOWED_EXTENSIONS = {".xlsx", ".xlsm", ".xls"} | ||
|
|
@@ -165,7 +169,7 @@ class DesignSnapshot(BaseModel): | |
| class OpenpyxlCellProtocol(Protocol): | ||
| """Protocol for openpyxl cell access used by patch runner.""" | ||
|
|
||
| value: str | int | float | None | ||
| value: PatchScalar | ||
| data_type: str | None | ||
| font: OpenpyxlFontProtocol | ||
| fill: OpenpyxlFillProtocol | ||
|
|
@@ -523,18 +527,22 @@ class PatchOp(BaseModel): | |
| default=None, | ||
| description="Base cell for formula translation in fill_formula (e.g. 'C2').", | ||
| ) | ||
| expected: str | int | float | None = Field( | ||
| expected: PatchScalar = 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( | ||
| value: PatchScalar = 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( | ||
| values: list[list[PatchScalar]] | None = Field( | ||
| default=None, | ||
| description="2D list of values for set_range_values. Shape must match the range dimensions.", | ||
| ) | ||
| value_type: PatchValueType = Field( | ||
| default="auto", | ||
| description="Interpretation hint for value. 'date' parses an ISO string into a real date/time cell instead of text (JSON has no date literal). For set_value and set_value_if.", | ||
| ) | ||
| formula: str | None = Field( | ||
| default=None, | ||
| description="Formula string starting with '=' (e.g. '=SUM(A1:A10)'). For set_formula, set_formula_if, fill_formula.", | ||
|
|
@@ -818,6 +826,7 @@ def _validate_optional_positive_width(cls, value: float | None) -> float | None: | |
|
|
||
| @model_validator(mode="after") | ||
| def _validate_op(self) -> PatchOp: | ||
| self.value = coerce_patch_scalar(self.value, self.value_type) | ||
| validator = _validator_for_op(self.op) | ||
| if validator is None: | ||
| return self | ||
|
|
@@ -1523,7 +1532,7 @@ class PatchValue(BaseModel): | |
| """Normalized before/after value in patch diff.""" | ||
|
|
||
| kind: PatchValueKind | ||
| value: str | int | float | None | ||
| value: PatchScalar | ||
|
|
||
|
|
||
| class PatchDiffItem(BaseModel): | ||
|
|
@@ -2865,7 +2874,7 @@ def _apply_openpyxl_cell_op( | |
|
|
||
| def _set_cell_value( | ||
| cell: OpenpyxlCellProtocol, | ||
| value: str | int | float | None, | ||
| value: PatchScalar, | ||
| auto_formula: bool, | ||
| *, | ||
| op_name: str, | ||
|
|
@@ -3129,7 +3138,7 @@ def _build_merge_value_loss_warning( | |
| ) | ||
|
|
||
|
|
||
| def _has_non_empty_cell_value(value: str | int | float | None) -> bool: | ||
| def _has_non_empty_cell_value(value: PatchScalar) -> bool: | ||
| """Return True when cell has a non-empty value.""" | ||
| if value is None: | ||
| return False | ||
|
|
@@ -3520,16 +3529,16 @@ def _translate_formula(formula: str, origin: str, target: str) -> str: | |
| return str(translated) | ||
|
|
||
|
|
||
| def _patch_value_to_primitive(value: PatchValue | None) -> str | int | float | None: | ||
| def _patch_value_to_primitive(value: PatchValue | None) -> PatchScalar: | ||
| """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, | ||
| current: PatchScalar, | ||
| expected: PatchScalar, | ||
| ) -> bool: | ||
| """Compare values for conditional update checks.""" | ||
| return current == expected | ||
|
|
@@ -3552,7 +3561,15 @@ def _build_inverse_cell_op( | |
| cell=cell_ref, | ||
| formula=str(before.value), | ||
| ) | ||
| return PatchOp(op="set_value", sheet=op.sheet, cell=cell_ref, value=before.value) | ||
| return PatchOp( | ||
| op="set_value", | ||
| sheet=op.sheet, | ||
| cell=cell_ref, | ||
| value=before.value, | ||
| # Without the hint the ISO string this serializes to replays as text, | ||
| # silently downgrading a date cell on undo. | ||
| value_type="date" if isinstance(before.value, datetime | date | time) else "auto", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve When 🤖 Prompt for AI Agents |
||
| ) | ||
|
|
||
|
|
||
| def _collect_formula_issues_openpyxl( | ||
|
|
@@ -4621,7 +4638,7 @@ def _apply_xlwings_cell_op( | |
|
|
||
| def _set_xlwings_cell_value( | ||
| cell: XlwingsRangeProtocol, | ||
| value: str | int | float | None, | ||
| value: PatchScalar, | ||
| auto_formula: bool, | ||
| *, | ||
| op_name: str, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| """Date-bearing cells must survive patch, inverse-op serialization, and undo.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime | ||
| import json | ||
| from pathlib import Path | ||
|
|
||
| from openpyxl import Workbook, load_workbook | ||
| import pytest | ||
|
|
||
| from exstruct.edit import PatchOp, PatchRequest, patch_workbook | ||
| from exstruct.edit.types import coerce_patch_scalar | ||
|
|
||
|
|
||
| def _workbook_with_date(path: Path) -> None: | ||
| workbook = Workbook() | ||
| sheet = workbook.active | ||
| assert sheet is not None | ||
| sheet.title = "Sheet1" | ||
| sheet["A1"] = datetime(2025, 1, 1) | ||
| workbook.save(path) | ||
| workbook.close() | ||
|
|
||
|
|
||
| def test_patching_a_date_cell_captures_the_date_as_before(tmp_path: Path) -> None: | ||
| """A date in the target cell used to fail PatchValue validation outright.""" | ||
| source = tmp_path / "book.xlsx" | ||
| _workbook_with_date(source) | ||
|
|
||
| result = patch_workbook( | ||
| PatchRequest( | ||
| xlsx_path=source, | ||
| ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="replaced")], | ||
| backend="openpyxl", | ||
| dry_run=True, | ||
| return_inverse_ops=True, | ||
| ) | ||
| ) | ||
|
|
||
| assert result.error is None | ||
| assert result.patch_diff[0].before is not None | ||
| assert result.patch_diff[0].before.value == datetime(2025, 1, 1) | ||
|
|
||
|
|
||
| def test_inverse_op_restores_a_real_date_through_json(tmp_path: Path) -> None: | ||
| """The undo script is written to JSON, so the date hint must survive the trip.""" | ||
| source = tmp_path / "book.xlsx" | ||
| patched = tmp_path / "patched.xlsx" | ||
| undone = tmp_path / "undone.xlsx" | ||
| _workbook_with_date(source) | ||
|
|
||
| forward = patch_workbook( | ||
| PatchRequest( | ||
| xlsx_path=source, | ||
| output_path=patched, | ||
| ops=[PatchOp(op="set_value", sheet="Sheet1", cell="A1", value="replaced")], | ||
| backend="openpyxl", | ||
| return_inverse_ops=True, | ||
| ) | ||
| ) | ||
| assert forward.error is None | ||
|
|
||
| # Round-trip the inverse ops the way the CLI does: dump to JSON, read back. | ||
| serialized = json.loads(json.dumps([op.model_dump(mode="json") for op in forward.inverse_ops])) | ||
| assert forward.out_path is not None | ||
| reverse = patch_workbook( | ||
| PatchRequest( | ||
| xlsx_path=Path(forward.out_path), | ||
| output_path=undone, | ||
| ops=[PatchOp.model_validate(op) for op in serialized], | ||
| backend="openpyxl", | ||
| ) | ||
| ) | ||
| assert reverse.error is None | ||
|
|
||
| assert reverse.out_path is not None | ||
| cell = load_workbook(reverse.out_path)["Sheet1"]["A1"] | ||
| assert cell.value == datetime(2025, 1, 1) | ||
| assert cell.data_type == "d", "undo downgraded the date cell to text" | ||
|
|
||
|
|
||
| def test_value_type_date_writes_a_date_not_a_string(tmp_path: Path) -> None: | ||
| source = tmp_path / "book.xlsx" | ||
| out = tmp_path / "out.xlsx" | ||
| _workbook_with_date(source) | ||
|
|
||
| result = patch_workbook( | ||
| PatchRequest( | ||
| xlsx_path=source, | ||
| output_path=out, | ||
| ops=[ | ||
| PatchOp( | ||
| op="set_value", | ||
| sheet="Sheet1", | ||
| cell="A1", | ||
| value="2026-12-25T00:00:00", | ||
| value_type="date", | ||
| ) | ||
| ], | ||
| backend="openpyxl", | ||
| ) | ||
| ) | ||
|
|
||
| assert result.error is None | ||
| assert result.out_path is not None | ||
| assert load_workbook(result.out_path)["Sheet1"]["A1"].value == datetime(2026, 12, 25) | ||
|
|
||
|
|
||
| def test_value_type_auto_leaves_an_iso_string_alone() -> None: | ||
| assert coerce_patch_scalar("2025-01-01", "auto") == "2025-01-01" | ||
|
|
||
|
|
||
| def test_value_type_date_rejects_non_iso_text() -> None: | ||
| with pytest.raises(ValueError, match="not an ISO date/time"): | ||
| coerce_patch_scalar("hello", "date") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject non-default
value_typefor unsupported operations.Both
PatchOp._validate_opimplementations callcoerce_patch_scalarfor every operation, while the operation validators do not rejectvalue_type. Therefore,set_formulaand other operations acceptvalue_type="date"and ignore it. The mini schema listsvalue_typeonly forset_valueandset_value_if. Reject non-"auto"values in both validators whenopis not one of those operations.🤖 Prompt for AI Agents