Skip to content

Commit 55e9cfa

Browse files
authored
docs: full SKILL/docs audit — changeset tier, dry_run, occurrence, exception sections (ISSUES.md #55) (#69)
Docs-only sync of README, docs/{api,index,quickstart}, and skills/docx/SKILL to the current API surface (changeset tier, dry_run, occurrence, count_matches, session status, new exception sections). Every claim verified against source; multi-reviewer + red-team pass fixed a stale-ref changeset example, an OOXML→XML accuracy nit, and batch_edit result-naming consistency.
1 parent 75fce50 commit 55e9cfa

5 files changed

Lines changed: 130 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Pure Python library for Word document track changes and comments, without requir
2323
- **Cross-Boundary Editing**: Find and replace text spanning multiple XML elements and revision boundaries
2424
- **Mixed-State Editing**: Atomic decomposition for text spanning `<w:ins>`/`<w:del>` boundaries
2525
- **Comments**: Add, reply, resolve, and delete comments
26-
- **Revision Management**: List, accept, and reject tracked changes
27-
- **Session Mode**: Optional persistent kernel (`docx-session start/exec/eval/stop`) keeps documents open across many small commands — ideal for AI agents (`pip install "docx-editor[session]"`)
26+
- **Revision Management**: List, accept, and reject tracked changes at three granularities — individual revisions, groups (one logical edit), and changesets (one whole `batch_edit`/`batch_rewrite` call); `EditResult` and `Revision` objects carry `group_id` and `changeset_id`
27+
- **Session Mode**: Optional persistent kernel (`docx-session start/exec/eval/status/stop`) keeps documents open across many small commands — ideal for AI agents (`pip install "docx-editor[session]"`)
2828
- **Cross-Platform**: Works on Linux, macOS, and Windows
2929
- **No Dependencies**: Only requires `defusedxml` for secure XML parsing
3030

@@ -89,6 +89,9 @@ with Document.open("contract.docx", author=author) as doc:
8989
doc.replace("net", "gross", paragraph=r) # chain without list_paragraphs()
9090
doc.delete("obsolete text", paragraph="P5#d4e5")
9191
doc.insert_after("Section 5", " (as amended)", paragraph="P3#b2c4")
92+
# Repeated text? Pass occurrence= (0-based) to pick which match; omitting it
93+
# requires a unique match, else AmbiguousTextError.
94+
doc.replace("the", "The", paragraph="P4#c5d6", occurrence=2)
9295

9396
# Rewrite entire paragraph (automatic word-level diff)
9497
doc.rewrite_paragraph("P2#f3c1",
@@ -152,6 +155,10 @@ with Document.open("contract.docx", author="Editor") as doc:
152155
doc.save()
153156
```
154157

158+
Pass `dry_run=True` to validate every operation up front without touching the
159+
document — `doc.batch_edit(ops, dry_run=True)` returns a list of
160+
`EditValidationResult` instead of applying the edits.
161+
155162
### Saving into synced folders
156163

157164
`docx-editor` is safe to use inside cloud-synced folders (OneDrive, Dropbox,

‎docs/api.md‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Return one paragraph as a structured `ParagraphInfo` record — the single-item
134134

135135
**Returns:** `ParagraphInfo` (index, hash-anchored ref, full untruncated text) for the paragraph at `index`.
136136

137-
**Raises:** [`ParagraphIndexError`](#exceptions) if `index` is out of range (`< 1` or greater than `paragraph_count()`).
137+
**Raises:** [`ParagraphIndexError`](#paragraphindexerror) if `index` is out of range (`< 1` or greater than `paragraph_count()`).
138138

139139
**Example:**
140140

@@ -154,7 +154,7 @@ Return the paragraphs surrounding `ref`, in document order — the "show me the
154154

155155
**Returns:** List of `ParagraphInfo` records (index, ref, full text) — identical to what `list_paragraphs_structured()` would emit for the same span.
156156

157-
**Raises:** `ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `ref`.
157+
**Raises:** `ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `ref`.
158158

159159
**Example:**
160160

@@ -246,7 +246,7 @@ Find text in the document, including text spanning XML element boundaries.
246246

247247
**Returns:** [`SearchResult`](#searchresult), or None if not found
248248

249-
**Raises:** `ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `paragraph`.
249+
**Raises:** `ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `paragraph`.
250250

251251
**Example:**
252252

@@ -275,7 +275,7 @@ what a follow-up edit needs.
275275

276276
**Returns:** list of [`SearchResult`](#searchresult), empty when nothing matches (no-match is not an error for an enumeration API)
277277

278-
**Raises:** `ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `paragraph`.
278+
**Raises:** `ValueError` if `text` is empty or `paragraph` is malformed; [`ParagraphIndexError`](#paragraphindexerror) / [`HashMismatchError`](#hashmismatcherror) for an out-of-range or stale `paragraph`.
279279

280280
**Example:**
281281

@@ -585,7 +585,7 @@ List all tracked changes in the document.
585585

586586
**Returns:** List of Revision objects
587587

588-
**Raises:** The `paragraph` ref is validated exactly like in the edit methods — `ValueError` (malformed ref), [`ParagraphIndexError`](#exceptions) (index out of range), [`HashMismatchError`](#exceptions) (stale hash).
588+
**Raises:** The `paragraph` ref is validated exactly like in the edit methods — `ValueError` (malformed ref), [`ParagraphIndexError`](#paragraphindexerror) (index out of range), [`HashMismatchError`](#hashmismatcherror) (stale hash).
589589

590590
**Example:**
591591

@@ -916,6 +916,10 @@ from docx_editor import Revision
916916
| `author` | str | The revision author |
917917
| `date` | datetime or None | When the revision was made |
918918
| `text` | str | The inserted or deleted text |
919+
| `paragraph_ref` | str or None | Hash-anchored reference (`"P{i}#{hash}"`) of the containing paragraph; None when the revision sits outside any `<w:p>` (e.g. a `<w:trPr>` row marker) |
920+
| `occurrence` | int or None | 0-based occurrence index of `text` within the containing paragraph, counted in the view where the revision's text lives (the visible view for insertions, the original pre-revision view for deletions). For insertions it plugs directly into the `occurrence=` parameter of the anchor APIs; None whenever targeting-by-text does not apply (empty text, a host insertion partly consumed by a nested deletion, or a nested deletion) |
921+
| `nested_under` | int or None | id of the nearest enclosing revision (e.g. a foreign deletion inside another author's pending insertion), else None |
922+
| `contains_ids` | tuple[int, ...] | ids of the revisions nested inside this one, in document order (empty tuple when none) |
919923
| `group_id` | int or None | Revision group this revision belongs to (see [`accept_group()`](#accept_groupgroup_id)): recorded for this session's edits, inferred by reconstruction for revisions already in the file; None only for ungroupable revisions (missing author/date, outside any paragraph, duplicated id, or a mid-session split half of a foreign insertion) |
920924
| `group_source` | str or None | Provenance of `group_id`: `"recorded"` (created through this open Document) or `"inferred"` (reconstructed at parse time from same-paragraph contiguity + identical author and date); None iff `group_id` is None |
921925
| `changeset_id` | int or None | Changeset (one whole call) this revision's group belongs to (see [`accept_changeset()`](#accept_changesetchangeset_id) / [`reject_changeset()`](#reject_changesetchangeset_id)) — the `(author, date)` class over groups; None iff `group_id` is None |
@@ -1161,6 +1165,41 @@ except AmbiguousTextError as e:
11611165
occurrence=r.paragraph_occurrence)
11621166
```
11631167

1168+
### `HashMismatchError`
1169+
1170+
Raised when a `paragraph` ref's hash no longer matches the paragraph's current
1171+
content — the paragraph changed (usually by an earlier edit) since the ref was
1172+
listed, so the ref is stale. Structured fields: `paragraph_index` (1-based),
1173+
`expected_hash` (the hash in the stale ref), `actual_hash` (the paragraph's
1174+
current hash), and `paragraph_preview` (the current text). Recover by retrying
1175+
with the fresh ref `P{paragraph_index}#{actual_hash}`, or re-list paragraphs.
1176+
1177+
```python
1178+
from docx_editor.exceptions import HashMismatchError
1179+
1180+
try:
1181+
doc.replace("old", "new", paragraph="P2#f3c1")
1182+
except HashMismatchError as e:
1183+
doc.replace("old", "new", paragraph=f"P{e.paragraph_index}#{e.actual_hash}")
1184+
```
1185+
1186+
### `ParagraphIndexError`
1187+
1188+
Raised when a paragraph index is out of range — `< 1` or greater than
1189+
`paragraph_count()` (for example `get_paragraph(0)`). Structured fields:
1190+
`index` (the offending index) and `total_paragraphs` (how many the document
1191+
has). Clamp to `1..total_paragraphs` and retry, or call `list_paragraphs()`
1192+
to pick a valid ref.
1193+
1194+
```python
1195+
from docx_editor.exceptions import ParagraphIndexError
1196+
1197+
try:
1198+
para = doc.get_paragraph(index)
1199+
except ParagraphIndexError as e:
1200+
para = doc.get_paragraph(max(1, min(e.index, e.total_paragraphs)))
1201+
```
1202+
11641203
### `BatchOperationError`
11651204

11661205
The only exception `batch_edit()` / `batch_rewrite()` raise for a failing
@@ -1214,6 +1253,18 @@ Raised by `Document.open()` when the source file does not exist. Structured fiel
12141253
from docx_editor.exceptions import DocumentNotFoundError
12151254
```
12161255

1256+
### `InvalidDocumentError`
1257+
1258+
Raised by `Document.open()` when the source path is not a valid `.docx` — wrong
1259+
suffix, a directory, an empty/truncated file, not a ZIP, missing
1260+
`word/document.xml`, or malformed XML. Structured field: `path` (the source
1261+
path). Not an in-loop retry: the message names which check failed; fix or
1262+
re-export the input file.
1263+
1264+
```python
1265+
from docx_editor.exceptions import InvalidDocumentError
1266+
```
1267+
12171268
### `DocumentClosedError`
12181269

12191270
Raised when any operation is attempted on a `Document` after `close()`. Closing discards the workspace (unless `cleanup=False`), so the object cannot keep serving reads or edits — reopen the source to continue. Structured field: `path` (the source path of the closed document).

‎docs/index.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Pure Python library for Word document track changes and comments, without requir
1111
- **Paragraph Rewrite**: rewrite a paragraph and generate tracked changes from the diff
1212
- **Track Changes**: Replace, delete, and insert text with revision tracking
1313
- **Comments**: Add, reply, resolve, and delete comments
14-
- **Revision Management**: List, accept, and reject tracked changes
14+
- **Revision Management**: Accept/reject tracked changes at three granularities — individual revisions, groups (one logical edit), and changesets (one whole `batch_edit`/`batch_rewrite` call)
1515
- **Cross-Boundary Editing**: Find and replace text spanning multiple XML elements
1616
- **Cross-Platform**: Works on Linux, macOS, and Windows
1717
- **No Dependencies**: Only requires `defusedxml` for secure XML parsing
@@ -38,6 +38,8 @@ with Document.open("contract.docx", author="Legal Team") as doc:
3838
# P2#f3c1| Payment is due within 30 days...
3939

4040
# Edit methods require a paragraph ref and return the updated ref.
41+
# Pass occurrence= (0-based) to disambiguate repeated text; omitting it
42+
# requires a unique match, else AmbiguousTextError.
4143
ref = doc.replace("30 days", "60 days", paragraph="P2#f3c1")
4244
ref = doc.insert_after("Payment", " terms", paragraph=ref)
4345
doc.delete("obsolete text", paragraph="P5#d4e5")

‎docs/quickstart.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ with Document.open("contract.docx") as doc:
7272

7373
Use the `P{index}#{hash}` part as the `paragraph=` argument. Edit methods return a new paragraph ref after the hash changes, so keep the returned value when chaining edits in the same paragraph.
7474

75+
When the search text appears more than once in the target paragraph, pass `occurrence=` (0-based, so `occurrence=1` is the second match) to `replace()`, `delete()`, `insert_after()`, `insert_before()`, and `add_comment()`. Omitting it requires a unique match — otherwise the call raises `AmbiguousTextError`.
76+
7577
## Track Changes
7678

7779
### Replace Text
@@ -83,7 +85,7 @@ with Document.open("contract.docx") as doc:
8385
doc.save()
8486
```
8587

86-
This creates a tracked deletion of `30 days` and a tracked insertion of `60 days`.
88+
This creates a tracked deletion of `30` and a tracked insertion of `60`. `replace()` trims the common prefix and suffix and tracks only the differing words, so the unchanged surrounding word (` days`) is left intact rather than deleted and re-inserted.
8789

8890
### Delete Text
8991

@@ -130,6 +132,18 @@ with Document.open("contract.docx") as doc:
130132
doc.save()
131133
```
132134

135+
To validate a batch before applying it, pass `dry_run=True`. This checks every operation up front and returns a list of `EditValidationResult` objects (each with `valid` and `error`) without modifying the document:
136+
137+
```python
138+
with Document.open("contract.docx") as doc:
139+
results = doc.batch_edit([
140+
EditOperation.replace("30 days", "60 days", paragraph="P2#f3c1"),
141+
EditOperation.delete("obsolete clause", paragraph="P5#d4e5"),
142+
], dry_run=True)
143+
for r in results:
144+
print(r.index, r.valid, r.error)
145+
```
146+
133147
## Comments
134148

135149
### Add a Comment
@@ -199,6 +213,28 @@ doc.accept_revision(revision_id=1)
199213
doc.reject_revision(revision_id=1)
200214
```
201215

216+
### Accept or Reject a Group or Changeset
217+
218+
Revisions form three nested tiers: an individual **revision**, the **group** of revisions from one logical edit (e.g. a single `replace()` or `rewrite_paragraph()`), and the **changeset** of all groups from one whole call (`batch_edit()` / `batch_rewrite()`). Accept or reject a whole tier in one call:
219+
220+
```python
221+
from docx_editor import EditOperation
222+
223+
# Every edit method returns an EditResult carrying group_id and changeset_id.
224+
result = doc.rewrite_paragraph("P3#b2c4", "Section 5 has been revised in full.")
225+
doc.accept_group(result.group_id) # resolve one logical edit as a unit
226+
227+
# One batch_edit is one changeset that may span several groups (edit paragraphs
228+
# the rewrite above didn't touch, so their refs are still fresh):
229+
new_refs = doc.batch_edit([
230+
EditOperation.replace("30 days", "60 days", paragraph="P2#f3c1"),
231+
EditOperation.delete("obsolete clause", paragraph="P5#d4e5"),
232+
])
233+
doc.reject_changeset(new_refs[0].changeset_id) # undo the whole batch in one call
234+
```
235+
236+
Every `Revision` also carries `group_id`/`group_source` and `changeset_id`/`changeset_source` — the source is `"recorded"` for edits made in this session and `"inferred"` for revisions reconstructed when an existing document is reopened. Group and changeset ids are renumbered each time the document is opened, so always resolve them with an id from the current session's `EditResult` or `list_revisions()`.
237+
202238
### Accept or Reject All
203239

204240
```python

0 commit comments

Comments
 (0)