Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docx_editor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
WorkspaceSyncError,
XMLError,
)
from .track_changes import EditOperation, Revision
from .track_changes import EditOperation, EditValidationResult, Revision
from .xml_editor import (
ParagraphInfo,
ParagraphLocation,
Expand All @@ -71,6 +71,7 @@
# Main classes
"Document",
"EditOperation",
"EditValidationResult",
"Revision",
"Comment",
# Exceptions
Expand Down
32 changes: 29 additions & 3 deletions docx_editor/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
import shutil
from collections.abc import Iterator
from pathlib import Path
from typing import Literal, overload
from xml.dom.minidom import Element

from .comments import Comment, CommentManager
from .exceptions import HashMismatchError, ParagraphIndexError
from .track_changes import EditOperation, Revision, RevisionManager
from .track_changes import EditOperation, EditValidationResult, Revision, RevisionManager
from .workspace import Workspace
from .xml_editor import (
DocxXMLEditor,
Expand Down Expand Up @@ -496,7 +497,15 @@ def insert_before(self, anchor: str, text: str, *, paragraph: str, occurrence: i
self._revision_manager.insert_text_before(anchor, text, occurrence=occurrence, paragraph=paragraph)
return self._compute_new_ref(paragraph)

def batch_edit(self, operations: list[EditOperation]) -> list[str]:
@overload
def batch_edit(self, operations: list[EditOperation], *, dry_run: Literal[False] = ...) -> list[str]: ...

@overload
def batch_edit(self, operations: list[EditOperation], *, dry_run: Literal[True]) -> list[EditValidationResult]: ...

def batch_edit(
self, operations: list[EditOperation], *, dry_run: bool = False
) -> list[str] | list[EditValidationResult]:
"""Apply multiple edits atomically with upfront hash validation.

All paragraph hashes are validated before any edits are applied.
Expand All @@ -506,17 +515,34 @@ def batch_edit(self, operations: list[EditOperation]) -> list[str]:

Args:
operations: List of EditOperation objects
dry_run: If True, validate every operation without applying any
edits and return a list of EditValidationResult (one per
operation, in input order). The document is left unchanged.
Each operation is validated independently against the current
document; sequential effects between multiple operations on the
same paragraph are not simulated (see
RevisionManager.validate_batch).

Returns:
List of new paragraph references with updated hashes, in input order.
When dry_run is False: list of new paragraph references with updated
hashes, in input order.
When dry_run is True: list of EditValidationResult, one per operation.

Example:
new_refs = doc.batch_edit([
EditOperation(action="replace", find="old", replace_with="new", paragraph="P20#a7b2"),
EditOperation(action="delete", text="remove", paragraph="P15#f3c1"),
])

# Pre-flight the batch (note: same-paragraph sequential effects are
# not simulated — see the dry_run note above):
results = doc.batch_edit(ops, dry_run=True)
if all(r.valid for r in results):
doc.batch_edit(ops)
"""
self._ensure_open()
if dry_run:
return self._revision_manager.validate_batch(operations)
self._revision_manager.batch_edit(operations)
return [self._compute_new_ref(op.paragraph) for op in operations]

Expand Down
151 changes: 120 additions & 31 deletions docx_editor/track_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ class EditOperation:
occurrence: int = 0


@dataclass
class EditValidationResult:
"""Outcome of validating one EditOperation in a dry-run batch."""

index: int # 0-based position in the input operations list
paragraph: str | None # the operation's paragraph ref (None if it was missing)
valid: bool # True if the op would apply cleanly
error: str | None = None # human-readable reason when not valid


@dataclass
class Revision:
"""Represents a tracked change (insertion or deletion)."""
Expand Down Expand Up @@ -169,50 +179,129 @@ def batch_edit(self, operations: list[EditOperation]) -> list[int]:
pass
raise

def _apply_single_edit(self, op: EditOperation) -> int:
"""Apply a single edit operation. Paragraph hash was already validated."""
ref = ParagraphRef.parse(op.paragraph)
p = self.editor.dom.getElementsByTagName("w:p")[ref.index - 1]
def _resolve_action_target(self, op: EditOperation) -> str:
"""Validate op's required args and return the text this op must locate.

Shared by ``_apply_single_edit`` and ``_validate_single`` so the two
paths cannot drift out of sync. Rejects a negative ``occurrence`` up
front (the one non-well-formed input ``_find_in_paragraph`` chokes on)
so both paths fail cleanly before the search.

Raises:
ValueError: If ``occurrence`` is negative, required arguments for
op.action are missing, or the action is unrecognized.
"""
if op.occurrence < 0:
raise ValueError(f"occurrence must be >= 0, got {op.occurrence}")

if op.action == "replace":
if not op.find or op.replace_with is None:
raise ValueError("replace requires 'find' and 'replace_with'")
match = self._find_in_paragraph(p, op.find, op.occurrence)
if match is None:
raise TextNotFoundError(
op.find,
paragraph_ref=op.paragraph,
paragraph_preview=self._paragraph_preview(p),
)
return self._replace_across_nodes(match, op.replace_with)

return op.find
elif op.action == "delete":
if not op.text:
raise ValueError("delete requires 'text'")
match = self._find_in_paragraph(p, op.text, op.occurrence)
if match is None:
raise TextNotFoundError(
op.text,
paragraph_ref=op.paragraph,
paragraph_preview=self._paragraph_preview(p),
)
return self._delete_across_nodes(match)

return op.text
elif op.action in ("insert_after", "insert_before"):
if not op.anchor or op.text is None:
raise ValueError(f"{op.action} requires 'anchor' and 'text'")
match = self._find_in_paragraph(p, op.anchor, op.occurrence)
if match is None:
raise TextNotFoundError(
op.anchor,
paragraph_ref=op.paragraph,
paragraph_preview=self._paragraph_preview(p),
)
return op.anchor
else:
raise ValueError(f"Unknown action: {op.action}")

def _apply_single_edit(self, op: EditOperation) -> int:
"""Apply a single edit operation. Paragraph hash was already validated."""
ref = ParagraphRef.parse(op.paragraph)
p = self.editor.dom.getElementsByTagName("w:p")[ref.index - 1]

target = self._resolve_action_target(op)
match = self._find_in_paragraph(p, target, op.occurrence)
if match is None:
raise TextNotFoundError(
target,
paragraph_ref=op.paragraph,
paragraph_preview=self._paragraph_preview(p),
)

if op.action == "replace":
assert op.replace_with is not None # guaranteed by _resolve_action_target
return self._replace_across_nodes(match, op.replace_with)
elif op.action == "delete":
return self._delete_across_nodes(match)
else: # insert_after / insert_before
assert op.text is not None # guaranteed by _resolve_action_target
position = "after" if op.action == "insert_after" else "before"
return self._insert_near_match(match, op.text, position)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
raise ValueError(f"Unknown action: {op.action}")
def validate_batch(self, operations: list[EditOperation]) -> list[EditValidationResult]:
"""Validate a batch of edits without applying any of them.

Mirrors the checks in ``batch_edit`` / ``_apply_single_edit`` (paragraph
ref format, hash freshness, per-action argument requirements, and target
text existence) but never raises and never mutates the document. Each
operation gets its own result so the caller sees the full picture even
when some ops are valid and others are not.

Limitation: each operation is validated independently against the
*current* document state; sequential effects are not simulated. A batch
with multiple operations on the same paragraph (where one op's edit
changes what a later op would see) may validate differently than it
applies. Cross-paragraph batches are unaffected, since edits never
change the paragraph count.

Args:
operations: List of EditOperation objects (each should have paragraph set)

Returns:
One EditValidationResult per operation, in input order.
"""
results = []
for i, op in enumerate(operations):
error = self._validate_single(op)
results.append(
EditValidationResult(
index=i,
paragraph=op.paragraph,
valid=error is None,
error=error,
)
)
return results

def _validate_single(self, op: EditOperation) -> str | None:
"""Return an error message if ``op`` would fail, or None if it is valid.

Reuses ``_resolve_paragraph``, ``_resolve_action_target``, and
``_find_in_paragraph`` — the same helpers ``_apply_single_edit`` uses —
so dry-run validation cannot drift from real application semantics.
Reads only.
"""
if not op.paragraph:
return "paragraph reference is required for batch mode"

try:
ref = ParagraphRef.parse(op.paragraph)
except ValueError as e:
return str(e)

try:
p = self._resolve_paragraph(ref)
except (ParagraphIndexError, HashMismatchError) as e:
return str(e)

# Resolve required args + the text this op must locate via the same
# helper _apply_single_edit uses (which also rejects a negative
# occurrence), so validation cannot drift from application semantics and
# the find below never raises.
try:
target = self._resolve_action_target(op)
except ValueError as e:
return str(e)

if self._find_in_paragraph(p, target, op.occurrence) is None:
return f"text {target!r} not found in paragraph {op.paragraph} ({self._paragraph_preview(p)!r})"

return None

def batch_rewrite(self, rewrites: list[tuple[str, str]]) -> None:
"""Rewrite multiple paragraphs with upfront hash validation."""
Expand Down
Loading
Loading