Skip to content

Commit a5a54a5

Browse files
authored
feat: find_all enumeration, occurrence ambiguity guard, batch operation_index (ISSUES.md #34) (#53)
* feat: find_all enumeration, occurrence ambiguity guard, batch operation_index (ISSUES.md #34) - Document.find_all(text, paragraph=None) enumerates every match as SearchResults whose paragraph_ref/paragraph_occurrence plug directly into follow-up edits. - Omitted occurrence now means the target must be unique in the search scope; ambiguous targets raise AmbiguousTextError (new exception with search_text, paragraph_ref, paragraph_preview, total_occurrences) instead of silently editing the first match. Explicit occurrence=0 keeps the old behavior per call. - Out-of-range occurrences in scoped edits report occurrence and total_occurrences instead of a self-contradicting 'not found'. - batch_edit/batch_rewrite raise only BatchOperationError for per-op failures (stale hash included), with operation_index and the typed underlying exception on original/__cause__. Batches stay atomic. - Occurrence handling consolidated into _locate_in_paragraph, replacing four duplicated find-or-raise paths; dry-run validation shares it. - Docs (SKILL.md, api.md) and benchmarks updated; 14 new find_all tests plus ambiguity, out-of-range, and single-exception-contract suites. * review: fix same-paragraph batch advice, dedupe occurrence counting Multi-reviewer findings on PR #53: - The find_all docs recommended batch_edit for several matches in one paragraph, but ops in find_all (ascending) order silently mis-target or fail: each edit shifts the occurrence numbering of the matches after it in the accepted text view. Corrected all three doc spots (Document.find_all docstring, docs/api.md, SKILL.md) to require descending occurrence order (an edit never shifts the matches before it) with the self-overlapping-string caveat, and added TestSameParagraphBatch locking in both the working descending pattern and the atomic failure of the ascending one. - Consolidated the two match-counting idioms into one count_in_text_map() helper in xml_editor.py, now used by _locate_in_paragraph, count_matches, and CommentManager._find_anchor (also drops count_matches' quadratic inner loop). - Dropped the one-off 'Note:' docstring heading in Document.find_all. Skipped (reviewed, deliberate): the extra count_matches pass on the document-wide occurrence=None success path — short-circuiting at the second match would break the exact total_occurrences the AmbiguousTextError contract promises. * review: batch_rewrite rollback, locate naming, exception message dedup Round-2 multi-reviewer findings on PR #53: - batch_rewrite now snapshots the DOM and rolls back on mid-apply failure, matching batch_edit — the documented 'nothing is applied on failure' contract previously rested entirely on upfront validation precluding every apply-phase error. Added a rollback test that simulates a mid-loop failure (none is reachable today). - Renamed _find_document_wide to _locate_document_wide: the _find_* helpers return None on miss, while this one raises like _locate_in_paragraph — the name now signals the find-or-raise contract. - TextNotFoundError docstring now covers the out-of-range occurrence case that docs/api.md already documented. - Factored the 'Current content' message suffix into _append_preview so TextNotFoundError and AmbiguousTextError build it identically instead of one being period-safe by coincidence. - Documented why _locate_document_wide counts via count_matches rather than count_in_text_map (text maps are per-paragraph; exact total is part of the error contract). Rejected finding: a claim that _apply_single_edit re-validates hashes mid-batch, breaking same-paragraph descending-order batches — refuted by the code (apply resolves by index only), the passing TestSameParagraphBatch, and an end-to-end repro. * review: complete locate naming, copy-paste-safe find_all examples Round-3 multi-reviewer findings on PR #53 (correctness reviewer CLEAN): - Renamed CommentManager._find_anchor to _locate_anchor, completing the find-or-raise naming convention round 2 introduced (_find_* helpers return None on miss; this one always raises). - The find_all examples in the docstring, api.md, and SKILL.md now show the pattern that is safe for any match distribution — one batch_edit over reversed(find_all(...)), which yields the required descending occurrence order for same-paragraph ops — instead of the sequential loop that only works with one match per paragraph. - api.md and SKILL.md now note BatchOperationError.original is None for batch-level rule violations (missing paragraph ref, duplicate paragraph) that have no underlying exception. * review: reject negative occurrence before the text-map search CodeRabbit findings on PR #53: - A negative occurrence passed to the direct (non-batch) edit methods reached find_in_text_map, where range(occurrence + 1) never runs and the scoped paths crashed with UnboundLocalError (document-wide paths produced a nonsensical 'occurrence=-1 requested' message). All three locate paths (_locate_in_paragraph, _locate_document_wide, CommentManager._locate_anchor) now reject negatives up front with the same ValueError the EditOperation constructors raise; regression test covers all four public paths. - Added the missing return annotation on the _edit_calls test helper. Skipped (deliberate, settled in earlier rounds): the second count_matches scan on the document-wide occurrence=None path — the exact total is part of the AmbiguousTextError contract and the trade-off is documented in _locate_document_wide's docstring.
1 parent d9833a1 commit a5a54a5

14 files changed

Lines changed: 1248 additions & 202 deletions

‎benchmarks/hash_anchored_vs_plain.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,13 @@ def benchmark_accuracy():
228228
shutil.copy(persist_path, d1)
229229
doc1 = Document.open(d1, force_recreate=True)
230230

231-
# Disrupting edit: replace BOTH 'committee' in P5
231+
# Disrupting edit: replace BOTH 'committee' in P5 (occurrence=0 twice —
232+
# after the first replace, the remaining match is the new first one)
232233
p5_ref = old_refs[5]
233-
doc1.replace("committee", "BOARD", paragraph=p5_ref)
234-
doc1.replace("committee", "BOARD", paragraph=old_refs[5].split("#")[0] + "#" + _get_fresh_hash(doc1, 5))
234+
doc1.replace("committee", "BOARD", paragraph=p5_ref, occurrence=0)
235+
doc1.replace(
236+
"committee", "BOARD", paragraph=old_refs[5].split("#")[0] + "#" + _get_fresh_hash(doc1, 5), occurrence=0
237+
)
235238

236239
# Now try to edit P6-P15 using the OLD occurrence indices
237240
for para_idx in target_paras:
@@ -283,15 +286,17 @@ def benchmark_accuracy():
283286
doc2 = Document.open(d2, force_recreate=True)
284287

285288
# Same disrupting edit
286-
doc2.replace("committee", "BOARD", paragraph=p5_ref)
287-
doc2.replace("committee", "BOARD", paragraph=old_refs[5].split("#")[0] + "#" + _get_fresh_hash(doc2, 5))
289+
doc2.replace("committee", "BOARD", paragraph=p5_ref, occurrence=0)
290+
doc2.replace(
291+
"committee", "BOARD", paragraph=old_refs[5].split("#")[0] + "#" + _get_fresh_hash(doc2, 5), occurrence=0
292+
)
288293

289294
# Try to edit P6-P15 using OLD refs (from before edit)
290295
for para_idx in target_paras:
291296
old_ref = old_refs[para_idx]
292297
marker = f"[P{para_idx:02d}]"
293298
try:
294-
doc2.replace("committee", "EDITED", paragraph=old_ref)
299+
doc2.replace("committee", "EDITED", paragraph=old_ref, occurrence=0)
295300
hash_correct += 1
296301
except HashMismatchError:
297302
hash_rejected += 1

‎docs/api.md‎

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,44 @@ if match:
222222
)
223223
```
224224

225+
#### `find_all(text, paragraph=None)`
226+
227+
Find every match of `text`, in document order. One call replaces the N+1
228+
`find_text` probes needed to enumerate N hits, and each result carries exactly
229+
what a follow-up edit needs.
230+
231+
**Parameters:**
232+
233+
- `text` (str): Text to search for (must be non-empty)
234+
- `paragraph` (str, optional): Paragraph reference (e.g. `P2#f3c1`) to scope the search. `None` searches the whole document. Defaults to None.
235+
236+
**Returns:** list of [`SearchResult`](#searchresult), empty when nothing matches (no-match is not an error for an enumeration API)
237+
238+
**Raises:** `ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `paragraph`.
239+
240+
**Example:**
241+
242+
```python
243+
# Edit every match in one atomic batch. reversed() puts same-paragraph ops in
244+
# the required descending occurrence order, so this is safe however the
245+
# matches are distributed:
246+
ops = [
247+
EditOperation.replace(r.text, "60 days",
248+
paragraph=r.paragraph_ref, occurrence=r.paragraph_occurrence)
249+
for r in reversed(doc.find_all("30 days"))
250+
]
251+
doc.batch_edit(ops)
252+
```
253+
254+
Editing one match at a time (`doc.replace(...)` per result) also works when
255+
every paragraph holds at most one match. With several matches in one
256+
paragraph, an edit invalidates the paragraph's remaining refs and shifts the
257+
occurrence numbers of the matches after it — either re-run `find_all` after
258+
each edit, or batch the same-paragraph ops in **descending** occurrence order
259+
as above; an edit never shifts the matches before it. (Ascending order
260+
mis-targets; descending is not valid for search strings that overlap
261+
themselves, e.g. `"aa"` in `"aaaa"`.)
262+
225263
#### `count_matches(text)`
226264

227265
Count visible text matches across the document.
@@ -239,7 +277,7 @@ if doc.count_matches("Section 5") > 1:
239277
print("Use paragraph refs and occurrence to target the intended match")
240278
```
241279

242-
#### `replace(find, replace_with, *, paragraph, occurrence=0)`
280+
#### `replace(find, replace_with, *, paragraph, occurrence=None)`
243281

244282
Replace text with tracked changes. When the target sits inside another author's pending insertion, that insertion is preserved: the matched text gets a nested `<w:del>` under your authorship and the replacement lands in your own sibling `<w:ins>` (Word's behavior), instead of silently rewriting the other author's proposal.
245283

@@ -248,7 +286,7 @@ Replace text with tracked changes. When the target sits inside another author's
248286
- `find` (str): Text to find and replace
249287
- `replace_with` (str): Replacement text
250288
- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
251-
- `occurrence` (int): Which occurrence within the paragraph, 0-based (0 = first). Defaults to 0.
289+
- `occurrence` (int | None): Which occurrence within the paragraph, 0-based (0 = first). Omitted → the target must be unique within the paragraph; if it matches more than once, [`AmbiguousTextError`](#ambiguoustexterror) is raised instead of silently editing the first match.
252290

253291
**Returns:** Updated paragraph reference (str)
254292

@@ -259,15 +297,15 @@ ref = doc.replace("30 days", "60 days", paragraph="P2#f3c1")
259297
doc.replace("net", "gross", paragraph=ref)
260298
```
261299

262-
#### `delete(text, *, paragraph, occurrence=0)`
300+
#### `delete(text, *, paragraph, occurrence=None)`
263301

264302
Mark text as deleted with tracked changes. Deleting text inside another author's pending insertion nests a `<w:del>` under your authorship inside their `<w:ins>`, preserving their proposal; only your own pending insertions are edited in place.
265303

266304
**Parameters:**
267305

268306
- `text` (str): Text to mark as deleted
269307
- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
270-
- `occurrence` (int): Which occurrence within the paragraph, 0-based (0 = first). Defaults to 0.
308+
- `occurrence` (int | None): Which occurrence within the paragraph, 0-based (0 = first). Omitted → the target must be unique within the paragraph; if it matches more than once, [`AmbiguousTextError`](#ambiguoustexterror) is raised instead of silently editing the first match.
271309

272310
**Returns:** Updated paragraph reference (str)
273311

@@ -277,7 +315,7 @@ Mark text as deleted with tracked changes. Deleting text inside another author's
277315
ref = doc.delete("obsolete clause", paragraph="P5#d4e5")
278316
```
279317

280-
#### `insert_after(anchor, text, *, paragraph, occurrence=0)`
318+
#### `insert_after(anchor, text, *, paragraph, occurrence=None)`
281319

282320
Insert text after anchor with tracked changes. An anchor inside another author's pending insertion produces your own sibling `<w:ins>` (splitting theirs when the anchor falls mid-content) rather than splicing your words into their proposal.
283321

@@ -286,7 +324,7 @@ Insert text after anchor with tracked changes. An anchor inside another author's
286324
- `anchor` (str): Text to find as insertion point
287325
- `text` (str): Text to insert after the anchor
288326
- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
289-
- `occurrence` (int): Which occurrence within the paragraph, 0-based (0 = first). Defaults to 0.
327+
- `occurrence` (int | None): Which occurrence within the paragraph, 0-based (0 = first). Omitted → the target must be unique within the paragraph; if it matches more than once, [`AmbiguousTextError`](#ambiguoustexterror) is raised instead of silently editing the first match.
290328

291329
**Returns:** Updated paragraph reference (str)
292330

@@ -296,7 +334,7 @@ Insert text after anchor with tracked changes. An anchor inside another author's
296334
ref = doc.insert_after("Section 5", " (as amended)", paragraph="P3#b2c4")
297335
```
298336

299-
#### `insert_before(anchor, text, *, paragraph, occurrence=0)`
337+
#### `insert_before(anchor, text, *, paragraph, occurrence=None)`
300338

301339
Insert text before anchor with tracked changes. Foreign pending insertions are treated the same as in `insert_after()`.
302340

@@ -305,7 +343,7 @@ Insert text before anchor with tracked changes. Foreign pending insertions are t
305343
- `anchor` (str): Text to find as insertion point
306344
- `text` (str): Text to insert before the anchor
307345
- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
308-
- `occurrence` (int): Which occurrence within the paragraph, 0-based (0 = first). Defaults to 0.
346+
- `occurrence` (int | None): Which occurrence within the paragraph, 0-based (0 = first). Omitted → the target must be unique within the paragraph; if it matches more than once, [`AmbiguousTextError`](#ambiguoustexterror) is raised instead of silently editing the first match.
309347

310348
**Returns:** Updated paragraph reference (str)
311349

@@ -344,6 +382,8 @@ stale, the entire batch is rejected before any edits are applied.
344382

345383
**Returns:** Updated paragraph references in input order (list[str]); with `dry_run=True`, a list of [`EditValidationResult`](#editvalidationresult) instead
346384

385+
**Raises:** [`BatchOperationError`](#batchoperationerror) — the only exception a non-dry-run batch raises for a failing operation, whatever the underlying cause (stale hash, malformed ref, missing text, ambiguous target). `operation_index` names the failing op; `original` (also `__cause__`) holds the underlying typed exception. The batch is atomic: nothing is applied on failure.
386+
347387
**Example:**
348388

349389
```python
@@ -380,6 +420,8 @@ Rewrite multiple paragraphs after validating paragraph hashes up front.
380420

381421
**Returns:** Updated paragraph references in input order (list[str])
382422

423+
**Raises:** [`BatchOperationError`](#batchoperationerror) — same single-exception contract as `batch_edit()`.
424+
383425
**Example:**
384426

385427
```python
@@ -391,7 +433,7 @@ new_refs = doc.batch_rewrite([
391433

392434
### Comment Methods
393435

394-
#### `add_comment(anchor_text, comment, *, paragraph=None, occurrence=0)`
436+
#### `add_comment(anchor_text, comment, *, paragraph=None, occurrence=None)`
395437

396438
Add a comment anchored to specific text. Anchors are located with the same
397439
visible-text search used by `count_matches()` and the tracked-change edit
@@ -403,7 +445,7 @@ smart-quote splits, `w:ins` wrappers) are found.
403445
- `anchor_text` (str): Text to attach the comment to
404446
- `comment` (str): The comment content
405447
- `paragraph` (str, optional): Paragraph reference (e.g. `P3#a7b2`) to scope the search. `None` searches the whole document. Defaults to None.
406-
- `occurrence` (int): Which occurrence to anchor to, 0-based (0 = first). Defaults to 0.
448+
- `occurrence` (int | None): Which occurrence to anchor to, 0-based (0 = first), counted within `paragraph` when given and document-wide otherwise. Omitted → the anchor must be unique in the search scope, else [`AmbiguousTextError`](#ambiguoustexterror).
407449

408450
**Returns:** The comment ID (int). IDs are allocated sequentially starting at 0 in a document with no existing comments — always use the returned ID rather than guessing.
409451

@@ -697,27 +739,27 @@ from docx_editor import EditOperation
697739

698740
### Constructors
699741

700-
#### `EditOperation.replace(find, replace_with, *, paragraph, occurrence=0)`
742+
#### `EditOperation.replace(find, replace_with, *, paragraph, occurrence=None)`
701743

702744
- `find` (str): Text to find and replace. Must be non-empty.
703745
- `replace_with` (str): Replacement text. Empty string is allowed (replacing
704746
with nothing is a valid tracked deletion).
705747

706-
#### `EditOperation.delete(text, *, paragraph, occurrence=0)`
748+
#### `EditOperation.delete(text, *, paragraph, occurrence=None)`
707749

708750
- `text` (str): Text to mark as deleted. Must be non-empty.
709751

710-
#### `EditOperation.insert_after(anchor, text, *, paragraph, occurrence=0)`
752+
#### `EditOperation.insert_after(anchor, text, *, paragraph, occurrence=None)`
711753

712-
#### `EditOperation.insert_before(anchor, text, *, paragraph, occurrence=0)`
754+
#### `EditOperation.insert_before(anchor, text, *, paragraph, occurrence=None)`
713755

714756
- `anchor` (str): Text to find as insertion point. Must be non-empty.
715757
- `text` (str): Text to insert.
716758

717759
All constructors also take:
718760

719761
- `paragraph` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
720-
- `occurrence` (int): Which occurrence within the paragraph, 0-based (0 = first). Defaults to 0. Must be >= 0.
762+
- `occurrence` (int | None): Which occurrence within the paragraph, 0-based (0 = first). Omitted → the target must be unique within the paragraph at apply time; if it matches more than once, the batch fails with a [`BatchOperationError`](#batchoperationerror) wrapping an [`AmbiguousTextError`](#ambiguoustexterror). Must be >= 0 when given.
721763

722764
**Raises:** `ValueError` at construction time if the paragraph ref is malformed,
723765
`occurrence` is negative, a search-target argument (`find`, delete `text`,
@@ -777,7 +819,8 @@ if all(r.valid for r in results):
777819

778820
## SearchResult
779821

780-
The result of `Document.find_text()`. Carries no XML/DOM internals.
822+
The result of `Document.find_text()` (a single match, or None) and
823+
`Document.find_all()` (a list of them). Carries no XML/DOM internals.
781824

782825
```python
783826
from docx_editor import SearchResult
@@ -833,7 +876,11 @@ genuinely need the internals (raw DOM positions), import them from
833876

834877
### `TextNotFoundError`
835878

836-
Raised when the specified text is not found in the document.
879+
Raised when the specified text is not found in the document, or when an
880+
explicit `occurrence` is out of range — the error then carries `occurrence`
881+
and `total_occurrences` and its message reports the actual count instead of
882+
claiming the text is absent. Other structured fields: `search_text`,
883+
`paragraph_ref`, `paragraph_preview`.
837884

838885
```python
839886
from docx_editor.exceptions import TextNotFoundError
@@ -844,6 +891,49 @@ except TextNotFoundError as e:
844891
print(f"Text not found: {e}")
845892
```
846893

894+
### `AmbiguousTextError`
895+
896+
Raised when an edit target matches more than once in the search scope and no
897+
`occurrence` was given. Not a `TextNotFoundError` subclass — the text *was*
898+
found. Structured fields: `search_text`, `paragraph_ref` (None when
899+
document-wide), `paragraph_preview` (None when document-wide),
900+
`total_occurrences`.
901+
902+
```python
903+
from docx_editor.exceptions import AmbiguousTextError
904+
905+
try:
906+
doc.replace("term", "clause", paragraph="P2#f3c1")
907+
except AmbiguousTextError as e:
908+
r = doc.find_all("term", paragraph=e.paragraph_ref)[0]
909+
doc.replace("term", "clause", paragraph=r.paragraph_ref,
910+
occurrence=r.paragraph_occurrence)
911+
```
912+
913+
### `BatchOperationError`
914+
915+
The only exception `batch_edit()` / `batch_rewrite()` raise for a failing
916+
operation. Structured fields: `operation_index` (0-based position of the
917+
failing op), `reason` (human-readable message), `original` (the underlying
918+
typed exception, also set as `__cause__`; `None` for batch-level rule
919+
violations that have no underlying exception, e.g. a missing paragraph ref or
920+
a duplicate paragraph in `batch_rewrite`).
921+
922+
```python
923+
from docx_editor.exceptions import BatchOperationError, HashMismatchError
924+
925+
while ops:
926+
try:
927+
doc.batch_edit(ops)
928+
break
929+
except BatchOperationError as e:
930+
if isinstance(e.original, HashMismatchError):
931+
op = ops[e.operation_index]
932+
op.paragraph = f"P{e.original.paragraph_index}#{e.original.actual_hash}"
933+
else:
934+
ops.pop(e.operation_index)
935+
```
936+
847937
### `CommentError`
848938

849939
Raised when a comment operation fails.

‎docx_editor/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from .comments import Comment
3838
from .document import Document
3939
from .exceptions import (
40+
AmbiguousTextError,
4041
BatchOperationError,
4142
CommentError,
4243
DocumentNotFoundError,
@@ -88,6 +89,7 @@
8889
"RevisionError",
8990
"CommentError",
9091
"TextNotFoundError",
92+
"AmbiguousTextError",
9193
"HashMismatchError",
9294
"ParagraphIndexError",
9395
"BatchOperationError",

0 commit comments

Comments
 (0)