Skip to content
20 changes: 13 additions & 7 deletions docx_editor/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TextMapMatch,
_escape_xml,
_generate_hex_id,
_require_valid_occurrence,
build_text_map,
compute_paragraph_hash,
count_in_text_map,
Expand Down Expand Up @@ -155,10 +156,16 @@ def add_comment(
``anchor_text`` matches more than once in the search scope.
HashMismatchError: If a paragraph reference's hash is stale.
ParagraphIndexError: If a paragraph reference's index is out of range.
CommentError: If ``anchor_text`` is empty.
CommentError: If ``anchor_text`` is not a non-empty string, or
``comment_text`` is not a string.
ValueError: If ``occurrence`` is negative or not an integer.
"""
if not anchor_text:
raise CommentError("anchor_text must not be empty")
if not isinstance(anchor_text, str) or not anchor_text:
raise CommentError(f"anchor_text must be a non-empty string, got {anchor_text!r}")
if not isinstance(comment_text, str):
# Must fail here, before _place_markers mutates document.xml —
# a crash later (html.escape) would leave orphaned range markers.
raise CommentError(f"comment_text must be a string, got {comment_text!r}")
_, match = self._locate_anchor(anchor_text, paragraph, occurrence)

comment_id = self.next_comment_id
Expand Down Expand Up @@ -190,11 +197,10 @@ def _locate_anchor(
``_locate_document_wide`` so comment anchors and edit anchors find the
same text and fail the same way: ``occurrence=None`` requires a unique
anchor (else AmbiguousTextError), an explicit out-of-range occurrence
reports the actual total, and a negative occurrence is rejected with
ValueError.
reports the actual total, and a negative or non-int occurrence is
rejected with ValueError.
"""
if occurrence is not None and occurrence < 0:
raise ValueError(f"occurrence must be >= 0, got {occurrence}")
_require_valid_occurrence(occurrence)
occ = occurrence if occurrence is not None else 0

if paragraph is not None:
Expand Down
31 changes: 29 additions & 2 deletions docx_editor/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
)


def _require_ref_string(paragraph: str) -> None:
"""Reject non-string paragraph refs before they can silently select the
RevisionManager's document-wide search branch (its ``paragraph=None``
mode is intentional at that layer, not at this one)."""
if not isinstance(paragraph, str):
raise ValueError(f"'paragraph' must be a paragraph ref string like 'P3#a7b2', got {type(paragraph).__name__}")


class Document:
"""Word document with track changes and comment support.

Expand Down Expand Up @@ -729,6 +737,9 @@ def replace(self, find: str, replace_with: str, *, paragraph: str, occurrence: i
for accept_group()/reject_group().

Raises:
ValueError: If ``find`` is not a non-empty string,
``replace_with`` is not a string, ``paragraph`` is not a ref
string, or ``occurrence`` is negative or not an integer.
TextNotFoundError: If ``find`` is absent or ``occurrence`` is out
of range for the paragraph.
AmbiguousTextError: If ``occurrence`` is omitted and ``find``
Expand All @@ -740,6 +751,7 @@ def replace(self, find: str, replace_with: str, *, paragraph: str, occurrence: i
doc.replace("other text", "new text", paragraph=new_ref)
"""
self._ensure_open()
_require_ref_string(paragraph)
change_id = self._revision_manager.replace_text(find, replace_with, occurrence=occurrence, paragraph=paragraph)
return self._edit_result(paragraph, self._revision_manager.group_id_of(change_id))

Expand All @@ -759,6 +771,8 @@ def delete(self, text: str, *, paragraph: str, occurrence: int | None = None) ->
revisions this edit created.

Raises:
ValueError: If ``text`` is not a non-empty string, ``paragraph``
is not a ref string, or ``occurrence`` is negative or not an integer.
TextNotFoundError: If ``text`` is absent or ``occurrence`` is out
of range for the paragraph.
AmbiguousTextError: If ``occurrence`` is omitted and ``text``
Expand All @@ -769,6 +783,7 @@ def delete(self, text: str, *, paragraph: str, occurrence: int | None = None) ->
new_ref = doc.delete("obsolete clause", paragraph="P2#f3c1")
"""
self._ensure_open()
_require_ref_string(paragraph)
change_id = self._revision_manager.suggest_deletion(text, occurrence=occurrence, paragraph=paragraph)
return self._edit_result(paragraph, self._revision_manager.group_id_of(change_id))

Expand All @@ -789,6 +804,9 @@ def insert_after(self, anchor: str, text: str, *, paragraph: str, occurrence: in
revisions this edit created.

Raises:
ValueError: If ``anchor`` is not a non-empty string, ``text`` is
not a string, ``paragraph`` is not a ref string, or
``occurrence`` is negative or not an integer.
TextNotFoundError: If ``anchor`` is absent or ``occurrence`` is
out of range for the paragraph.
AmbiguousTextError: If ``occurrence`` is omitted and ``anchor``
Expand All @@ -799,6 +817,7 @@ def insert_after(self, anchor: str, text: str, *, paragraph: str, occurrence: in
new_ref = doc.insert_after("Section 5", " (as amended)", paragraph="P2#f3c1")
"""
self._ensure_open()
_require_ref_string(paragraph)
change_id = self._revision_manager.insert_text_after(anchor, text, occurrence=occurrence, paragraph=paragraph)
return self._edit_result(paragraph, self._revision_manager.group_id_of(change_id))

Expand All @@ -819,6 +838,9 @@ def insert_before(self, anchor: str, text: str, *, paragraph: str, occurrence: i
revisions this edit created.

Raises:
ValueError: If ``anchor`` is not a non-empty string, ``text`` is
not a string, ``paragraph`` is not a ref string, or
``occurrence`` is negative or not an integer.
TextNotFoundError: If ``anchor`` is absent or ``occurrence`` is
out of range for the paragraph.
AmbiguousTextError: If ``occurrence`` is omitted and ``anchor``
Expand All @@ -829,6 +851,7 @@ def insert_before(self, anchor: str, text: str, *, paragraph: str, occurrence: i
new_ref = doc.insert_before("Section 6", "New clause: ", paragraph="P2#f3c1")
"""
self._ensure_open()
_require_ref_string(paragraph)
change_id = self._revision_manager.insert_text_before(anchor, text, occurrence=occurrence, paragraph=paragraph)
return self._edit_result(paragraph, self._revision_manager.group_id_of(change_id))

Expand Down Expand Up @@ -867,8 +890,9 @@ def batch_edit(

Raises:
BatchOperationError: The only exception a non-dry-run batch raises
for a failing operation — validation (malformed ref, stale
hash, bad index) and apply (missing text, ambiguous target)
for a failing operation — validation (element is not an
EditOperation, malformed ref, stale hash, bad index) and apply
(missing text, ambiguous target)
failures alike. ``operation_index`` names the failing op and
``original`` (also ``__cause__``) holds the underlying typed
exception (e.g. a HashMismatchError with ``actual_hash``).
Expand Down Expand Up @@ -993,6 +1017,9 @@ def add_comment(
AmbiguousTextError: If ``occurrence`` is omitted and
``anchor_text`` matches more than once in the search scope.
HashMismatchError: If ``paragraph``'s hash is stale.
CommentError: If ``anchor_text`` is not a non-empty string, or
``comment`` is not a string.
ValueError: If ``occurrence`` is negative or not an integer.

Example:
doc.add_comment("Section 5", "Please review this section")
Expand Down
Loading
Loading