From f59c1e8a48e6690136c1ff241e17313641f08642 Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Fri, 17 Jul 2026 03:42:19 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20LLM=20token=20ergonomics=20?= =?UTF-8?q?=E2=80=94=20list=5Fparagraphs=20default=20cap,=20compact=20Sear?= =?UTF-8?q?chResult,=20context()=20helper=20(ISSUES.md=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_paragraphs: bare calls now return at most 200 paragraphs; when more remain, a trailing "... N more paragraphs; use start=… or limit=None" notice gives the next start. Notices always begin with "..." and never match the P{i}#{hash} ref shape. limit=None restores the full listing. - list_paragraphs_structured: same 200-record default cap, silent (no notice record) to keep the result homogeneously typed; detect truncation by comparing len(result) with paragraph_count(). - SearchResult: new paragraph_index field (1-based, same integer as in paragraph_ref) and a compact one-line repr/str (SearchResult(P3#a7b2 occ=1 '30 days'), trailing spans_rev marker). - Document.context(ref, window=2): the paragraphs around a ref as ParagraphInfo records, clamped at document edges; shares ref validation with get_paragraph_location via the extracted _resolve_validated_ref(). - Internal callers that consume entries as refs (benchmarks, conftest find_ref, docstring example) now pass limit=None; README/quickstart/ SKILL.md/api.md rewritten to the notice-based pagination idiom; the docx-session eval help example uses limit=None so filtered output cannot silently miss matches past P200. --- README.md | 13 +-- benchmarks/corpus/corpus_harness.py | 2 +- benchmarks/hash_anchored_vs_plain.py | 10 +- docs/api.md | 50 +++++--- docs/quickstart.md | 10 +- docx_editor/document.py | 167 +++++++++++++++++++++------ docx_editor/session.py | 2 +- docx_editor/track_changes.py | 15 +++ skills/docx/SKILL.md | 20 +++- tests/conftest.py | 2 +- tests/test_document.py | 103 +++++++++++++++++ tests/test_find_all.py | 53 +++++++++ tests/test_paragraph_hash.py | 123 +++++++++++++++++++- 13 files changed, 493 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 7c810a4..68ea548 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,12 @@ with Document.open("reviewed.docx", author="Editor") as doc: # List paragraphs to find hash-anchored references refs = doc.list_paragraphs() - # Page through large documents — you choose the page size; refs stay - # globally indexed (page 2 with size 50 starts at P51, not P1) - total = doc.paragraph_count() - page_size = 50 - for start in range(1, total + 1, page_size): - for ref in doc.list_paragraphs(start=start, limit=page_size): - print(ref) # process this page of refs + # Large documents: a bare call returns at most 200 paragraphs, ending + # with a "... N more paragraphs; use start=201 or limit=None" notice. + # Refs stay globally indexed across pages (page 2 starts at P201, not P1). + page1 = doc.list_paragraphs() # up to P200, then the notice + page2 = doc.list_paragraphs(start=201) # next page, per the notice + everything = doc.list_paragraphs(limit=None) # uncapped, never a notice # Find text across element boundaries match = doc.find_text("Aim: To") diff --git a/benchmarks/corpus/corpus_harness.py b/benchmarks/corpus/corpus_harness.py index ada4e3f..0acba7e 100644 --- a/benchmarks/corpus/corpus_harness.py +++ b/benchmarks/corpus/corpus_harness.py @@ -152,7 +152,7 @@ def fail_rest(from_stage: str): try: # Stage 2: read try: - paras = doc.list_paragraphs_structured() + paras = doc.list_paragraphs_structured(limit=None) visible = doc.get_visible_text() revs = doc.list_revisions() stages["read"] = { diff --git a/benchmarks/hash_anchored_vs_plain.py b/benchmarks/hash_anchored_vs_plain.py index 7e4c84d..1058b49 100644 --- a/benchmarks/hash_anchored_vs_plain.py +++ b/benchmarks/hash_anchored_vs_plain.py @@ -115,7 +115,7 @@ def open_saved(): hash_times = [] for _ in range(iterations): d, t = open_saved() - refs = d.list_paragraphs() + refs = d.list_paragraphs(limit=None) # Find P15's ref ref = None for entry in refs: @@ -167,7 +167,7 @@ def benchmark_accuracy(): print("=" * 60) doc, tmp = build_multi_paragraph_doc(30) - paragraphs = doc.list_paragraphs() + paragraphs = doc.list_paragraphs(limit=None) n_paras = len(paragraphs) print(f" Paragraphs: {n_paras}") @@ -338,7 +338,7 @@ def benchmark_accuracy(): def _get_fresh_hash(doc, para_index): """Get the current hash for a paragraph by index (1-based).""" - entries = doc.list_paragraphs() + entries = doc.list_paragraphs(limit=None) entry = entries[para_index - 1] ref = entry.split("|")[0] return ref.split("#")[1] @@ -376,7 +376,7 @@ def open_saved(): d, t = open_saved() t0 = time.perf_counter() for i in range(1, n_edits + 1): - refs = d.list_paragraphs() + refs = d.list_paragraphs(limit=None) ref = refs[i - 1].split("|")[0] d.replace(f"item {i}", f"EDIT_{i}", paragraph=ref) t1 = time.perf_counter() @@ -388,7 +388,7 @@ def open_saved(): for _ in range(iterations): d, t = open_saved() t0 = time.perf_counter() - refs = d.list_paragraphs() + refs = d.list_paragraphs(limit=None) ops = [ EditOperation( action="replace", diff --git a/docs/api.md b/docs/api.md index c959d07..1f19179 100644 --- a/docs/api.md +++ b/docs/api.md @@ -99,32 +99,50 @@ Return the total number of paragraphs in the document. A cheap bounds check for count = doc.paragraph_count() ``` -#### `list_paragraphs(max_chars=80, *, start=1, limit=None)` +#### `list_paragraphs(max_chars=80, *, start=1, limit=200)` List paragraphs with hash-anchored references. Refs are **1-based global** indexes (`P1`, `P2`, …) and stay correct across pages — a slice starting at paragraph 51 emits `P51#…`, not `P1#…`. +*Changed in 0.6.1:* a bare call now returns at most 200 paragraphs (previously all of them). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a **truncation notice** instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`. Notice lines always start with `...` and never match the `P{index}#{hash}` ref shape; filter them with `entry.startswith("...")` when consuming entries as refs. Pass `limit=None` for the full, notice-free listing. + **Parameters:** - `max_chars` (int): Maximum preview length (must be `>= 0`). Use `0` for refs only (e.g. `P1#a7b2`), with no preview or `| ` separator. - `start` (int): 1-based index of the first paragraph to return (default 1). A `start` beyond the last paragraph yields an empty list. -- `limit` (int | None): Maximum number of paragraphs to return, or `None` for all paragraphs from `start` onward (default `None`). +- `limit` (int | None): Maximum number of paragraphs to return (default 200), or `None` for all paragraphs from `start` onward. -**Returns:** List of strings in the form `P{index}#{hash}| preview text`, or bare `P{index}#{hash}` (no `| ` separator) when `max_chars=0`. +**Returns:** List of strings in the form `P{index}#{hash}| preview text`, or bare `P{index}#{hash}` (no `| ` separator) when `max_chars=0` — plus one trailing `... N more paragraphs; use start=… or limit=None` notice when the window did not reach the end of the document. **Example:** ```python -refs = doc.list_paragraphs() -for ref in refs: - print(ref) +page1 = doc.list_paragraphs() # up to P200, then "... N more" notice +page2 = doc.list_paragraphs(start=201) # next page, per the notice +refs = [e for e in page1 if not e.startswith("...")] # drop the notice line +everything = doc.list_paragraphs(limit=None) # uncapped, never a notice +``` -# Page through a large document — you pick the page size; refs stay -# globally indexed (with size 50, page 2 starts at P51) -count = doc.paragraph_count() -page_size = 50 -for start in range(1, count + 1, page_size): - for ref in doc.list_paragraphs(start=start, limit=page_size): - print(ref) # process this page of refs +`list_paragraphs_structured()` (same `start`/`limit` semantics, returns typed `ParagraphInfo` records with full untruncated text) shares the 200-record default cap but appends **no notice** — every entry stays a `ParagraphInfo`. Detect truncation by comparing `len(result)` with `paragraph_count()`, or pass `limit=None`. + +#### `context(ref, window=2)` + +Return the paragraphs surrounding `ref`, in document order — the "show me the section around this match" helper. Fetches the referenced paragraph plus up to `window` paragraphs on each side, clamped at the document edges (no padding, no wrap-around). + +**Parameters:** + +- `ref` (str): Paragraph reference (e.g. `P3#a7b2`) from `list_paragraphs()`, `find_text()`/`find_all()`, or an edit result. +- `window` (int): Paragraphs to include on *each side* of the referenced one (default 2, so up to 5 records). Must be `>= 0`; `0` returns just the referenced paragraph. + +**Returns:** List of `ParagraphInfo` records (index, ref, full text) — identical to what `list_paragraphs_structured()` would emit for the same span. + +**Raises:** `ValueError` if `ref` is malformed or `window < 0`; [`ParagraphIndexError`](#exceptions) / [`HashMismatchError`](#exceptions) for an out-of-range or stale `ref`. + +**Example:** + +```python +match = doc.find_text("Termination") +for info in doc.context(match.paragraph_ref, window=2): + print(info) # "P{i}#{hash}| full paragraph text" ``` #### `get_paragraph_location(ref)` @@ -945,6 +963,7 @@ from docx_editor import SearchResult | `paragraph_ref` | str | Hash-anchored ref like `P3#a7b2`, usable as the `paragraph=` argument of follow-up edits | | `paragraph_occurrence` | int | Occurrence index of this match within its paragraph, usable as the `occurrence=` argument of follow-up edits | | `spans_revision` | bool | True if the match crosses a tracked-revision boundary | +| `paragraph_index` | int | 1-based index of the containing paragraph — the same integer embedded in `paragraph_ref`, so you never string-parse the ref | `start`/`end` are offsets within the matched paragraph's visible text, **not** document-wide offsets. Coordinate systems differ between search and edit: @@ -955,6 +974,11 @@ document-wide offsets. Coordinate systems differ between search and edit: is computed at search time and — like refs from `list_paragraphs()` — goes stale once that paragraph is edited. +`repr()`/`str()` are compact one-liners — +`SearchResult(P3#a7b2 occ=1 '30 days')`, with a trailing `spans_rev` marker +when `spans_revision` is true — so printing a whole `find_all()` list stays +cheap. Every field remains accessible as an attribute. + ### Example ```python diff --git a/docs/quickstart.md b/docs/quickstart.md index 54d1e93..f6b3fe7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -61,15 +61,13 @@ P2#f3c1| Payment is due within 30 days... P3#b2c4| Section 5 describes review obligations... ``` -For large documents, page through paragraphs with `start`/`limit`. You choose the page size; refs keep their global 1-based index (with a page size of 50, page 2 starts at `P51`, not `P1`): +A bare `list_paragraphs()` call returns at most 200 paragraphs. When more remain, the last entry is a truncation notice (e.g. `"... 50 more paragraphs; use start=201 or limit=None"`) telling you the next `start`; notice lines always begin with `...` and never look like refs. Refs keep their global 1-based index across pages (page 2 starts at `P201`, not `P1`): ```python with Document.open("contract.docx") as doc: - total = doc.paragraph_count() - page_size = 50 - for start in range(1, total + 1, page_size): - for paragraph in doc.list_paragraphs(start=start, limit=page_size): - print(paragraph) + page1 = doc.list_paragraphs() # up to P200, then "... N more" notice + page2 = doc.list_paragraphs(start=201) # next page, per the notice + everything = doc.list_paragraphs(limit=None) # uncapped, never a notice ``` 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. diff --git a/docx_editor/document.py b/docx_editor/document.py index d9a7d80..f4f3d7a 100644 --- a/docx_editor/document.py +++ b/docx_editor/document.py @@ -39,6 +39,10 @@ compute_paragraph_hash, ) +# Default page size for list_paragraphs / list_paragraphs_structured. Bounds +# the output of a bare call on large documents; pass limit=None for everything. +_DEFAULT_LIST_LIMIT = 200 + def _require_ref_string(paragraph: str) -> None: """Reject non-string paragraph refs before they can silently select the @@ -231,6 +235,9 @@ def find_text(self, text: str, occurrence: int = 0, paragraph: str | None = None document-wide). - ``spans_revision``: True if the match crosses a tracked-revision boundary (e.g. part of it is inside a tracked insertion). + - ``paragraph_index``: 1-based index of the containing paragraph — + the same integer embedded in ``paragraph_ref``, provided so you + never need to string-parse the ref. Example: match = doc.find_text("30 days") @@ -345,7 +352,9 @@ def paragraph_count(self) -> int: self._ensure_open() return len(self._document_editor.dom.getElementsByTagName("w:p")) - def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | None = None) -> list[str]: + def list_paragraphs( + self, max_chars: int = 80, *, start: int = 1, limit: int | None = _DEFAULT_LIST_LIMIT + ) -> list[str]: """List paragraphs with hash-anchored references. Returns a list of strings like "P1#a7b2| Introduction to the..." @@ -353,6 +362,15 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N are **1-based global** indexes (P1, P2, …) and stay correct across pages — a slice starting at paragraph 51 emits "P51#…", not "P1#…". + .. versionchanged:: 0.6.1 + A bare call now returns at most 200 paragraphs (it used to return + all of them). Whenever paragraphs remain beyond the returned + window, the last list entry is a truncation notice instead of a + paragraph, e.g. ``"... 50 more paragraphs; use start=201 or + limit=None"``. Notice lines always start with ``"..."`` and never + match the ``P{i}#{hash}`` ref shape, so ref-consuming code can + filter them out. Pass ``limit=None`` to restore the full listing. + Args: max_chars: Maximum characters for the preview text (default 80). Must be >= 0. Use 0 to get only the hash refs (e.g. "P1#a7b2"), @@ -360,29 +378,32 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N start: 1-based index of the first paragraph to return (default 1). Must be >= 1. A ``start`` beyond the last paragraph yields an empty list. - limit: Maximum number of paragraphs to return, or ``None`` for all - paragraphs from ``start`` onward (default ``None``). Must be - >= 0 when given; ``0`` yields an empty list. + limit: Maximum number of paragraphs to return (default 200), or + ``None`` for all paragraphs from ``start`` onward. Must be + >= 0 when given. Returns: - List of hash-tagged paragraph preview strings. + List of hash-tagged paragraph preview strings, plus one trailing + ``"... N more paragraphs; use start=… or limit=None"`` notice + when the window did not reach the end of the document. Raises: ValueError: If ``max_chars`` < 0, ``start`` < 1, or ``limit`` < 0. Example: - # Caller chooses the page size; ``start`` walks forward by it. - count = doc.paragraph_count() - page_size = 50 - for start in range(1, count + 1, page_size): - for ref in doc.list_paragraphs(start=start, limit=page_size): - print(ref) + # Walk a large document page by page; the trailing notice on each + # page tells you the next start. + page = doc.list_paragraphs() # P1..P200 + notice + page = doc.list_paragraphs(start=201) # P201.. and so on + everything = doc.list_paragraphs(limit=None) # no cap, no notice """ self._ensure_open() if max_chars < 0: raise ValueError(f"max_chars must be >= 0, got {max_chars}") result = [] + last_index = start - 1 # highest index emitted; start-1 when the slice is empty for i, p in self._iter_paragraph_slice(start, limit): + last_index = i h = compute_paragraph_hash(p) if max_chars == 0: result.append(f"P{i}#{h}") @@ -392,6 +413,9 @@ def list_paragraphs(self, max_chars: int = 80, *, start: int = 1, limit: int | N if len(tm.text) > max_chars: preview += "..." result.append(f"P{i}#{h}| {preview}") + remaining = self.paragraph_count() - last_index + if remaining > 0: + result.append(f"... {remaining} more paragraphs; use start={last_index + 1} or limit=None") return result def _iter_paragraph_slice(self, start: int, limit: int | None) -> Iterator[tuple[int, Element]]: @@ -415,7 +439,9 @@ def _iter_paragraph_slice(self, start: int, limit: int | None) -> Iterator[tuple end = begin + limit if limit is not None else None return enumerate(paragraphs[begin:end], start=begin + 1) - def list_paragraphs_structured(self, *, start: int = 1, limit: int | None = None) -> list[ParagraphInfo]: + def list_paragraphs_structured( + self, *, start: int = 1, limit: int | None = _DEFAULT_LIST_LIMIT + ) -> list[ParagraphInfo]: """List paragraphs as structured :class:`ParagraphInfo` records. Like :meth:`list_paragraphs`, but returns named records (index, ref, @@ -431,26 +457,33 @@ def list_paragraphs_structured(self, *, start: int = 1, limit: int | None = None across slices, with the same ``start``/``limit`` semantics as :meth:`list_paragraphs`. + .. versionchanged:: 0.6.1 + A bare call now returns at most 200 records (it used to return + all of them). Unlike :meth:`list_paragraphs`, **no truncation + notice is appended** — every entry is a :class:`ParagraphInfo`, + never a string — so a capped result is silent. To detect + truncation, compare ``len(result)`` (or the last record's + ``index``) against :meth:`paragraph_count`. Pass ``limit=None`` + for the full listing. + Args: start: 1-based index of the first paragraph to return (default 1). Must be >= 1. A ``start`` beyond the last paragraph yields an empty list. - limit: Maximum number of paragraphs to return, or ``None`` for all - paragraphs from ``start`` onward (default ``None``). Must be + limit: Maximum number of paragraphs to return (default 200), or + ``None`` for all paragraphs from ``start`` onward. Must be >= 0 when given; ``0`` yields an empty list. Returns: - List of :class:`ParagraphInfo` records. + List of :class:`ParagraphInfo` records (no notice entries). Raises: ValueError: If ``start`` < 1, or ``limit`` < 0. Example: - # Caller chooses the page size; ``start`` walks forward by it. - page_size = 50 - for start in range(1, doc.paragraph_count() + 1, page_size): - for info in doc.list_paragraphs_structured(start=start, limit=page_size): - print(info.ref, info.text) + infos = doc.list_paragraphs_structured(limit=None) # everything + if len(infos) < doc.paragraph_count(): + ... # a bounded call was truncated """ self._ensure_open() result = [] @@ -490,6 +523,80 @@ def get_paragraph(self, index: int) -> ParagraphInfo: h = compute_paragraph_hash(p) return ParagraphInfo(index=index, ref=f"P{index}#{h}", text=build_text_map(p).text) + def context(self, ref: str, window: int = 2) -> list[ParagraphInfo]: + """Return the paragraphs surrounding ``ref``, in document order. + + Fetches the referenced paragraph plus up to ``window`` paragraphs on + each side (fewer at the document edges) — the "show me what's around + this match" helper for search results: pass a + :class:`~docx_editor.track_changes.SearchResult`'s ``paragraph_ref`` + straight in. The records are identical to what + :meth:`list_paragraphs_structured` would emit for the same indexes. + + Args: + ref: Paragraph reference (e.g., "P3#a7b2") from + :meth:`list_paragraphs`, :meth:`find_text`/:meth:`find_all`, + or an edit result. + window: Number of paragraphs to include on *each side* of the + referenced one (default 2, so up to 5 records). Must be >= 0; + ``0`` returns just the referenced paragraph. Clamped at the + document edges — no padding, no wrap-around. + + Returns: + List of :class:`ParagraphInfo` records covering + ``max(1, i - window) .. min(paragraph_count(), i + window)``, + where ``i`` is the referenced paragraph's index. + + Raises: + ValueError: If ``ref`` has an invalid format, or ``window`` < 0. + ParagraphIndexError: If the paragraph index is out of range. + HashMismatchError: If the hash no longer matches current + paragraph content (paragraph was modified after the ref + was captured). + + Example: + match = doc.find_text("Termination") + for info in doc.context(match.paragraph_ref, window=2): + print(info) + """ + self._ensure_open() + if window < 0: + raise ValueError(f"window must be >= 0, got {window}") + index, _ = self._resolve_validated_ref(ref) + first = max(1, index - window) + last = min(self.paragraph_count(), index + window) + return self.list_paragraphs_structured(start=first, limit=last - first + 1) + + def _resolve_validated_ref(self, ref: str) -> tuple[int, Element]: + """Parse ``ref``, bounds-check its index, and verify its hash. + + Shared validation for the ref-taking read methods + (:meth:`get_paragraph_location`, :meth:`context`). + + Returns: + ``(index, paragraph_element)`` — the ref's 1-based index and the + ``w:p`` element it resolves to. + + Raises: + ValueError: If ``ref`` has an invalid format. + ParagraphIndexError: If the paragraph index is out of range. + HashMismatchError: If the hash no longer matches current + paragraph content. + """ + parsed = ParagraphRef.parse(ref) + paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + if parsed.index < 1 or parsed.index > len(paragraphs): + raise ParagraphIndexError(parsed.index, len(paragraphs)) + p = paragraphs[parsed.index - 1] + actual_hash = compute_paragraph_hash(p) + if actual_hash != parsed.hash: + tm = build_text_map(p) + preview = tm.text[:80] + if len(tm.text) > 80: + preview += "..." + raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview) + return parsed.index, p + def _style_maps(self) -> tuple[dict[str, int], dict[str, ListItem]]: """Outline-level and numbering maps defined by paragraph styles. @@ -580,21 +687,11 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation: print(f"section {loc.section}") """ self._ensure_open() - parsed = ParagraphRef.parse(ref) + index, p = self._resolve_validated_ref(ref) paragraphs = self._document_editor.dom.getElementsByTagName("w:p") - if parsed.index < 1 or parsed.index > len(paragraphs): - raise ParagraphIndexError(parsed.index, len(paragraphs)) - p = paragraphs[parsed.index - 1] - actual_hash = compute_paragraph_hash(p) - if actual_hash != parsed.hash: - tm = build_text_map(p) - preview = tm.text[:80] - if len(tm.text) > 80: - preview += "..." - raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview) style_outlines, style_numbering = self._style_maps() - heading_path = _compute_heading_paths(paragraphs[: parsed.index], style_outlines)[-1] - section = _compute_section_indexes(paragraphs[: parsed.index])[-1] + heading_path = _compute_heading_paths(paragraphs[:index], style_outlines)[-1] + section = _compute_section_indexes(paragraphs[:index])[-1] return _compute_paragraph_location( p, style_outlines=style_outlines, @@ -1134,7 +1231,9 @@ def list_revisions(self, author: str | None = None, paragraph: str | None = None Example: # Reviewer workflow: inspect one paragraph's revisions, then act. - for ref in doc.list_paragraphs(max_chars=0): + # limit=None: every entry must be a real ref, never a truncation + # notice, because each one is passed as paragraph= below. + for ref in doc.list_paragraphs(max_chars=0, limit=None): for r in doc.list_revisions(paragraph=ref): print(f"{r.id}: {r.type} '{r.text}' by {r.author}") doc.accept_revision(3) diff --git a/docx_editor/session.py b/docx_editor/session.py index 1ba1589..5afce53 100644 --- a/docx_editor/session.py +++ b/docx_editor/session.py @@ -532,7 +532,7 @@ def _read_code(arg: str) -> str: easy route for expressions starting with '-' (argparse would read a bare "-x" as a flag): docx-session eval - <<'PY' -[str(p) for p in doc.list_paragraphs() if 'deadline' in str(p)] +[str(p) for p in doc.list_paragraphs(limit=None) if 'deadline' in str(p)] PY""" diff --git a/docx_editor/track_changes.py b/docx_editor/track_changes.py index fe20e53..cd81a80 100644 --- a/docx_editor/track_changes.py +++ b/docx_editor/track_changes.py @@ -257,6 +257,14 @@ class SearchResult: methods count within one paragraph — ``paragraph_occurrence`` bridges the two: pass it as the ``occurrence=`` of a follow-up edit to target exactly the match ``find_text`` located. + + ``paragraph_index`` is the 1-based document-order index of the containing + paragraph — the same integer embedded in ``paragraph_ref`` — so consumers + never need to string-parse the ref to compare or sort by position. + + ``repr()``/``str()`` are compact one-liners + (``SearchResult(P3#a7b2 occ=0 '30 days')``) so printing a list of results + stays cheap; every field remains accessible as an attribute. """ start: int # Start offset in the paragraph's visible text @@ -265,6 +273,11 @@ class SearchResult: paragraph_ref: str # Hash-anchored ref like "P3#a7b2" paragraph_occurrence: int # Occurrence index of this match within its paragraph spans_revision: bool # True if the match crosses a tracked-revision boundary + paragraph_index: int # 1-based index of the containing paragraph (same as in paragraph_ref) + + def __repr__(self) -> str: + spans = " spans_rev" if self.spans_revision else "" + return f"SearchResult({self.paragraph_ref} occ={self.paragraph_occurrence} {self.text!r}{spans})" @dataclass(frozen=True) @@ -1252,6 +1265,7 @@ def find_text(self, text: str, occurrence: int = 0, paragraph: str | None = None paragraph_ref=f"P{located.paragraph_index}#{compute_paragraph_hash(located.paragraph)}", paragraph_occurrence=located.paragraph_occurrence, spans_revision=located.match.spans_boundary, + paragraph_index=located.paragraph_index, ) def find_all(self, text: str, paragraph: str | None = None) -> list[SearchResult]: @@ -1300,6 +1314,7 @@ def find_all(self, text: str, paragraph: str | None = None) -> list[SearchResult paragraph_ref=paragraph_ref, paragraph_occurrence=local_occ, spans_revision=match.spans_boundary, + paragraph_index=idx, ) ) local_occ += 1 diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md index dfc4c75..b2fa75c 100644 --- a/skills/docx/SKILL.md +++ b/skills/docx/SKILL.md @@ -247,6 +247,15 @@ doc.batch_edit(ops) # order as above (an edit never shifts the matches before it; ascending # mis-targets, and descending is not valid for self-overlapping search strings # like "aa" in "aaaa"). +# SearchResults print compactly — SearchResult(P3#a7b2 occ=1 '30 days') — so +# printing a whole find_all() list is cheap. Each result also carries +# paragraph_index (the int inside paragraph_ref); never string-parse refs. + +# Show the paragraphs AROUND a match ("what's the surrounding section?"). +# window=2 (default) returns up to 5 ParagraphInfo records, clamped at the +# document edges; each prints as "P{i}#{hash}| full text": +for info in doc.context(match.paragraph_ref, window=2): + print(info) # Get all visible text (inserted text included, deleted text excluded) visible = doc.get_visible_text() @@ -624,15 +633,18 @@ with Document.open("file.docx", author=author) as doc: doc.save() ``` -Refs are **1-based** global indexes (`P1`, not `P0`). For large documents, page through paragraphs to save tokens — `paragraph_count()` gives the total for bounds, and `start`/`limit` return a slice whose refs keep their global index. You pick the page size (there's no built-in limit); with `limit=N` the next page starts at `start + N`. Pass `max_chars=0` to get bare refs (`P1#a7b2`) with no preview text or `| ` separator: +Refs are **1-based** global indexes (`P1`, not `P0`). A bare `list_paragraphs()` call returns at most **200 paragraphs** (the default `limit`). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a truncation notice instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`, telling you the next `start`. Notice lines always begin with `...` and never match the `P{i}#{hash}` ref shape, so filter them with `entry.startswith("...")` when consuming entries as refs. `paragraph_count()` gives the total for bounds; `start`/`limit` return a slice whose refs keep their global index; `limit=None` removes the cap. Pass `max_chars=0` to get bare refs (`P1#a7b2`) with no preview text or `| ` separator: ```python -total = doc.paragraph_count() # cheap bounds check -page_size = 50 # caller's choice -page2 = doc.list_paragraphs(start=1 + page_size, limit=page_size) # refs P51..P100 +page1 = doc.list_paragraphs() # up to P200, then "... N more" notice +page2 = doc.list_paragraphs(start=201) # next page, per the notice +refs = [e for e in page1 if not e.startswith("...")] # drop the notice line +everything = doc.list_paragraphs(limit=None) # uncapped, never a notice refs_only = doc.list_paragraphs(max_chars=0) # "P1#a7b2", no preview ``` +(`list_paragraphs_structured()` has the same 200-record default cap but appends **no notice** — every entry stays a typed `ParagraphInfo`. Detect truncation by comparing `len(result)` with `paragraph_count()`, or pass `limit=None`.) + The `paragraph` argument is **required** for all edit methods. If the paragraph content has changed since you called `list_paragraphs()`, a `HashMismatchError` is raised — preventing edits to the wrong location. **Every edit method returns an `EditResult` — a `str` subclass whose value is the new paragraph ref (plus `group_id`/`revision_ids`).** Chain edits without calling `list_paragraphs()` again: diff --git a/tests/conftest.py b/tests/conftest.py index a42a6f5..9afd19b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ def find_ref(doc, text): """Find the paragraph ref containing the given text.""" - for entry in doc.list_paragraphs(): + for entry in doc.list_paragraphs(limit=None): if text in entry: return entry.split("|")[0] raise ValueError(f"Paragraph containing '{text}' not found") diff --git a/tests/test_document.py b/tests/test_document.py index be0fefb..7eea7a0 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -15,7 +15,9 @@ DocumentNotFoundError, DocumentOpenError, DocxEditError, + HashMismatchError, InvalidDocumentError, + ParagraphIndexError, WorkspaceSyncError, ) from docx_editor.workspace import Workspace @@ -843,6 +845,107 @@ def test_find_text_after_close_raises(self, clean_workspace): doc.find_text("test") +class TestDocumentContext: + """Document.context() — the paragraphs around a ref, clamped at the edges.""" + + @pytest.fixture + def ten_para_docx(self, simple_docx, temp_dir): + """simple.docx with its body swapped for 10 one-line paragraphs.""" + body = "".join(f"Paragraph {i}" for i in range(1, 11)) + dest = temp_dir / "ten.docx" + replace_document_xml(simple_docx, dest, f"{body}") + return dest + + def test_middle_returns_full_window(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(5).ref + records = doc.context(ref) # default window=2 -> 5 records + assert [info.index for info in records] == [3, 4, 5, 6, 7] + # Records are exactly what the structured listing emits for the span. + assert records == doc.list_paragraphs_structured(start=3, limit=5) + finally: + doc.close() + + def test_clamps_at_document_start(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(1).ref + records = doc.context(ref, window=3) + assert [info.index for info in records] == [1, 2, 3, 4] + finally: + doc.close() + + def test_clamps_at_document_end(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(10).ref + records = doc.context(ref, window=3) + assert [info.index for info in records] == [7, 8, 9, 10] + finally: + doc.close() + + def test_window_zero_returns_just_the_paragraph(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(4).ref + assert doc.context(ref, window=0) == [doc.get_paragraph(4)] + finally: + doc.close() + + def test_search_result_ref_plugs_in(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + match = doc.find_text("Paragraph 7") + assert match is not None + records = doc.context(match.paragraph_ref, window=1) + assert [info.index for info in records] == [6, 7, 8] + finally: + doc.close() + + def test_negative_window_raises(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(1).ref + with pytest.raises(ValueError, match="window must be >= 0"): + doc.context(ref, window=-1) + finally: + doc.close() + + def test_malformed_ref_raises_value_error(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + with pytest.raises(ValueError, match="Invalid paragraph reference"): + doc.context("not-a-ref") + finally: + doc.close() + + def test_stale_hash_raises(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + ref = doc.get_paragraph(2).ref + doc.replace("Paragraph 2", "Mutated", paragraph=ref) + with pytest.raises(HashMismatchError): + doc.context(ref) + finally: + doc.close() + + def test_out_of_range_raises(self, ten_para_docx): + doc = Document.open(ten_para_docx) + try: + with pytest.raises(ParagraphIndexError): + doc.context("P999#0000") + finally: + doc.close() + + def test_after_close_raises(self, ten_para_docx): + doc = Document.open(ten_para_docx) + ref = doc.get_paragraph(1).ref + doc.close() + with pytest.raises(DocumentClosedError, match="closed"): + doc.context(ref) + + class TestPublicApiSurface: """The text-map internals are deprecated at the top level; SearchResult is public.""" diff --git a/tests/test_find_all.py b/tests/test_find_all.py index 918cab9..d12e540 100644 --- a/tests/test_find_all.py +++ b/tests/test_find_all.py @@ -140,6 +140,59 @@ def test_match_spanning_revision_sets_flag(self, multi_para_doc): assert plain[0].spans_revision is False +class TestSearchResultErgonomics: + """0.6.1 token-ergonomics contract: paragraph_index field + compact repr.""" + + def test_paragraph_index_matches_ref_document_wide(self, multi_para_doc): + doc, _ = multi_para_doc + results = doc.find_all("committee") + assert results, "fixture must produce matches" + for r in results: + assert r.paragraph_index == int(r.paragraph_ref.split("#")[0][1:]) + + def test_paragraph_index_matches_ref_scoped(self, multi_para_doc): + doc, _ = multi_para_doc + ref = doc.list_paragraphs()[2].split("|")[0] + for r in doc.find_all("committee", paragraph=ref): + assert r.paragraph_ref == ref + assert r.paragraph_index == 3 + + def test_find_text_carries_paragraph_index(self, multi_para_doc): + doc, _ = multi_para_doc + match = doc.find_text("[P05]") + assert match is not None + assert match.paragraph_index == 5 + assert match.paragraph_ref.startswith("P5#") + + def test_repr_is_compact_one_liner(self, multi_para_doc): + doc, _ = multi_para_doc + r = doc.find_all("item")[0] + text = repr(r) + assert "\n" not in text + assert r.paragraph_ref in text + assert f"occ={r.paragraph_occurrence}" in text + assert "'item'" in text + # No dataclass field-name boilerplate; short matches stay short. + assert "start=" not in text + assert "paragraph_ref=" not in text + assert len(text) < 60 + # str() shares the compact form (dataclasses have no separate __str__). + assert str(r) == text + + def test_repr_spans_rev_marker_only_when_spanning(self, multi_para_doc): + doc, _ = multi_para_doc + ref = doc.list_paragraphs()[0].split("|")[0] + doc.insert_after("review", "XX", paragraph=ref) + + spanning = doc.find_all("reviewXX")[0] + assert spanning.spans_revision is True + assert repr(spanning).endswith(" spans_rev)") + + plain = doc.find_all("[P01]")[0] + assert plain.spans_revision is False + assert "spans_rev" not in repr(plain) + + class TestFindAllScoped: def test_scoped_returns_only_that_paragraphs_hits(self, multi_para_doc): doc, _ = multi_para_doc diff --git a/tests/test_paragraph_hash.py b/tests/test_paragraph_hash.py index d5a576b..8a3b031 100644 --- a/tests/test_paragraph_hash.py +++ b/tests/test_paragraph_hash.py @@ -7,6 +7,7 @@ import defusedxml.minidom import pytest +from conftest import replace_document_xml from docx_editor import ( Document, @@ -192,11 +193,14 @@ def test_pagination_slice(self, temp_docx): full = doc.list_paragraphs() assert len(full) >= 3, "fixture needs >=3 paragraphs for this test" page = doc.list_paragraphs(start=2, limit=2) - assert len(page) == 2 + # Two refs plus a continuation notice (the 4-paragraph fixture + # has one paragraph left beyond the window). + assert len(page) == 3 # Global 1-based indexing preserved across pages assert page[0].startswith("P2#") assert page[1].startswith("P3#") - assert page == full[1:3] + assert page[:2] == full[1:3] + assert page[2] == "... 1 more paragraphs; use start=4 or limit=None" finally: doc.close() @@ -229,17 +233,23 @@ def test_max_chars_zero_refs_only(self, temp_docx): finally: doc.close() - def test_default_behavior_unchanged(self, temp_docx): + def test_small_doc_default_equals_unbounded(self, temp_docx): + # Below the 200-paragraph default cap, a bare call is identical to + # limit=None: every paragraph, no truncation notice. doc = Document.open(temp_docx) try: + assert doc.paragraph_count() < 200 assert doc.list_paragraphs() == doc.list_paragraphs(start=1, limit=None) finally: doc.close() - def test_pagination_limit_zero_returns_empty(self, temp_docx): + def test_pagination_limit_zero_returns_only_notice(self, temp_docx): + # limit=0 returns no refs, but the uniform notice rule still reports + # everything that remains. doc = Document.open(temp_docx) try: - assert doc.list_paragraphs(limit=0) == [] + count = doc.paragraph_count() + assert doc.list_paragraphs(limit=0) == [f"... {count} more paragraphs; use start=1 or limit=None"] finally: doc.close() @@ -269,6 +279,109 @@ def test_negative_max_chars_raises(self, temp_docx): doc.close() +# ==================== Default-cap / truncation-notice Tests ==================== + + +class TestListParagraphsDefaultCap: + """The 0.6.1 token-ergonomics contract: bare calls cap at 200 paragraphs.""" + + TOTAL = 250 + + @pytest.fixture + def big_docx(self, simple_docx, temp_dir): + """simple.docx with its body swapped for 250 one-line paragraphs.""" + body = "".join(f"Paragraph {i}" for i in range(1, self.TOTAL + 1)) + dest = temp_dir / "big.docx" + replace_document_xml(simple_docx, dest, f"{body}") + return dest + + def test_default_cap_returns_200_plus_notice(self, big_docx): + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs() + assert len(result) == 201 + assert result[0].startswith("P1#") + assert result[199].startswith("P200#") + assert result[200] == "... 50 more paragraphs; use start=201 or limit=None" + finally: + doc.close() + + def test_limit_none_returns_everything_no_notice(self, big_docx): + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs(limit=None) + assert len(result) == self.TOTAL + assert all(re.match(r"^P\d+#[0-9a-f]{4}\| ", entry) for entry in result) + finally: + doc.close() + + def test_explicit_limit_notice_reports_next_start(self, big_docx): + # The notice is truncation-based, not default-based: explicit + # paginating callers get the continuation hint too. + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs(start=11, limit=30) + assert len(result) == 31 + assert result[0].startswith("P11#") + assert result[29].startswith("P40#") + assert result[30] == "... 210 more paragraphs; use start=41 or limit=None" + finally: + doc.close() + + def test_window_reaching_end_has_no_notice(self, big_docx): + doc = Document.open(big_docx) + try: + last_page = doc.list_paragraphs(start=201, limit=50) + assert len(last_page) == 50 + assert last_page[-1].startswith("P250#") + exact_fit = doc.list_paragraphs(start=1, limit=self.TOTAL) + assert len(exact_fit) == self.TOTAL + assert exact_fit[-1].startswith("P250#") + finally: + doc.close() + + def test_start_beyond_total_stays_empty(self, big_docx): + doc = Document.open(big_docx) + try: + assert doc.list_paragraphs(start=self.TOTAL + 1) == [] + finally: + doc.close() + + def test_notice_never_matches_ref_pattern(self, big_docx): + # The documented filter rule: notice lines start with "..." and can + # never be mistaken for a P{i}#{hash} ref. + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs() + assert result[-1].startswith("...") + assert not re.match(r"^P\d+#", result[-1]) + refs = [entry for entry in result if not entry.startswith("...")] + assert len(refs) == 200 + finally: + doc.close() + + def test_structured_default_cap_no_notice(self, big_docx): + # The structured variant caps too, but stays homogeneously typed: + # no notice record, truncation detected via paragraph_count(). + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs_structured() + assert len(result) == 200 + assert all(isinstance(info, ParagraphInfo) for info in result) + assert len(result) < doc.paragraph_count() + finally: + doc.close() + + def test_structured_limit_none_returns_everything(self, big_docx): + doc = Document.open(big_docx) + try: + result = doc.list_paragraphs_structured(limit=None) + assert len(result) == self.TOTAL + assert result[-1].index == self.TOTAL + finally: + doc.close() + + # ==================== list_paragraphs_structured Tests ==================== From 5194aa97f8d634fb87b140aaff9e13a0522b070b Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Fri, 17 Jul 2026 04:07:33 +0200 Subject: [PATCH 2/3] =?UTF-8?q?polish:=20review=20round=201=20=E2=80=94=20?= =?UTF-8?q?render-safe=20version=20notes,=20robust=20truncation=20detectio?= =?UTF-8?q?n,=20occ=3D0=20examples,=20singular=20notice,=20no=20redundant?= =?UTF-8?q?=20DOM=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document.py: '.. versionchanged::' reST directives replaced with Google-style 'Note:' sections (mkdocstrings' google parser renders the directive as literal text); list_paragraphs_structured docstring example now demonstrates truncation detection against a bounded call via the last record's index (the limit=None example could never trigger the check); notice noun pluralizes ('1 more paragraph'); _resolve_validated_ref returns the validated paragraph list so get_paragraph_location no longer re-queries the DOM. - docs/api.md: bold 'Changed in 0.6.1:' label (file convention), occ=0 in the repr example (paragraph_occurrence is 0-based), index-based truncation rule. - SKILL.md: occ=0 repr example, index-based truncation rule. --- docs/api.md | 6 ++-- docx_editor/document.py | 57 ++++++++++++++++++++---------------- skills/docx/SKILL.md | 4 +-- tests/test_paragraph_hash.py | 3 +- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/docs/api.md b/docs/api.md index 1f19179..3487716 100644 --- a/docs/api.md +++ b/docs/api.md @@ -103,7 +103,7 @@ count = doc.paragraph_count() List paragraphs with hash-anchored references. Refs are **1-based global** indexes (`P1`, `P2`, …) and stay correct across pages — a slice starting at paragraph 51 emits `P51#…`, not `P1#…`. -*Changed in 0.6.1:* a bare call now returns at most 200 paragraphs (previously all of them). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a **truncation notice** instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`. Notice lines always start with `...` and never match the `P{index}#{hash}` ref shape; filter them with `entry.startswith("...")` when consuming entries as refs. Pass `limit=None` for the full, notice-free listing. +**Changed in 0.6.1:** a bare call now returns at most 200 paragraphs (previously all of them). Whenever paragraphs remain beyond the returned window — default or explicit `limit` — the last list entry is a **truncation notice** instead of a paragraph, e.g. `"... 50 more paragraphs; use start=201 or limit=None"`. Notice lines always start with `...` and never match the `P{index}#{hash}` ref shape; filter them with `entry.startswith("...")` when consuming entries as refs. Pass `limit=None` for the full, notice-free listing. **Parameters:** @@ -122,7 +122,7 @@ refs = [e for e in page1 if not e.startswith("...")] # drop the notice line everything = doc.list_paragraphs(limit=None) # uncapped, never a notice ``` -`list_paragraphs_structured()` (same `start`/`limit` semantics, returns typed `ParagraphInfo` records with full untruncated text) shares the 200-record default cap but appends **no notice** — every entry stays a `ParagraphInfo`. Detect truncation by comparing `len(result)` with `paragraph_count()`, or pass `limit=None`. +`list_paragraphs_structured()` (same `start`/`limit` semantics, returns typed `ParagraphInfo` records with full untruncated text) shares the 200-record default cap but appends **no notice** — every entry stays a `ParagraphInfo`. Detect truncation by checking whether the last record's `index` is still below `paragraph_count()` (robust for any `start`), or pass `limit=None`. #### `context(ref, window=2)` @@ -975,7 +975,7 @@ is computed at search time and — like refs from `list_paragraphs()` — goes stale once that paragraph is edited. `repr()`/`str()` are compact one-liners — -`SearchResult(P3#a7b2 occ=1 '30 days')`, with a trailing `spans_rev` marker +`SearchResult(P3#a7b2 occ=0 '30 days')`, with a trailing `spans_rev` marker when `spans_revision` is true — so printing a whole `find_all()` list stays cheap. Every field remains accessible as an attribute. diff --git a/docx_editor/document.py b/docx_editor/document.py index f4f3d7a..0a34250 100644 --- a/docx_editor/document.py +++ b/docx_editor/document.py @@ -362,14 +362,15 @@ def list_paragraphs( are **1-based global** indexes (P1, P2, …) and stay correct across pages — a slice starting at paragraph 51 emits "P51#…", not "P1#…". - .. versionchanged:: 0.6.1 - A bare call now returns at most 200 paragraphs (it used to return - all of them). Whenever paragraphs remain beyond the returned - window, the last list entry is a truncation notice instead of a - paragraph, e.g. ``"... 50 more paragraphs; use start=201 or - limit=None"``. Notice lines always start with ``"..."`` and never - match the ``P{i}#{hash}`` ref shape, so ref-consuming code can - filter them out. Pass ``limit=None`` to restore the full listing. + Note: + Changed in 0.6.1: a bare call now returns at most 200 paragraphs + (it used to return all of them). Whenever paragraphs remain beyond + the returned window, the last list entry is a truncation notice + instead of a paragraph, e.g. ``"... 50 more paragraphs; use + start=201 or limit=None"``. Notice lines always start with + ``"..."`` and never match the ``P{i}#{hash}`` ref shape, so + ref-consuming code can filter them out. Pass ``limit=None`` to + restore the full listing. Args: max_chars: Maximum characters for the preview text (default 80). @@ -415,7 +416,8 @@ def list_paragraphs( result.append(f"P{i}#{h}| {preview}") remaining = self.paragraph_count() - last_index if remaining > 0: - result.append(f"... {remaining} more paragraphs; use start={last_index + 1} or limit=None") + noun = "paragraph" if remaining == 1 else "paragraphs" + result.append(f"... {remaining} more {noun}; use start={last_index + 1} or limit=None") return result def _iter_paragraph_slice(self, start: int, limit: int | None) -> Iterator[tuple[int, Element]]: @@ -457,14 +459,15 @@ def list_paragraphs_structured( across slices, with the same ``start``/``limit`` semantics as :meth:`list_paragraphs`. - .. versionchanged:: 0.6.1 - A bare call now returns at most 200 records (it used to return - all of them). Unlike :meth:`list_paragraphs`, **no truncation - notice is appended** — every entry is a :class:`ParagraphInfo`, - never a string — so a capped result is silent. To detect - truncation, compare ``len(result)`` (or the last record's - ``index``) against :meth:`paragraph_count`. Pass ``limit=None`` - for the full listing. + Note: + Changed in 0.6.1: a bare call now returns at most 200 records (it + used to return all of them). Unlike :meth:`list_paragraphs`, **no + truncation notice is appended** — every entry is a + :class:`ParagraphInfo`, never a string — so a capped result is + silent. To detect truncation, check whether the last record's + ``index`` is still below :meth:`paragraph_count` (robust for any + ``start``; with ``start=1``, comparing ``len(result)`` works too). + Pass ``limit=None`` for the full listing. Args: start: 1-based index of the first paragraph to return (default 1). @@ -481,9 +484,10 @@ def list_paragraphs_structured( ValueError: If ``start`` < 1, or ``limit`` < 0. Example: - infos = doc.list_paragraphs_structured(limit=None) # everything - if len(infos) < doc.paragraph_count(): - ... # a bounded call was truncated + infos = doc.list_paragraphs_structured() # bounded: at most 200 + if infos and infos[-1].index < doc.paragraph_count(): + ... # truncated — continue from start=infos[-1].index + 1 + everything = doc.list_paragraphs_structured(limit=None) """ self._ensure_open() result = [] @@ -567,15 +571,16 @@ def context(self, ref: str, window: int = 2) -> list[ParagraphInfo]: last = min(self.paragraph_count(), index + window) return self.list_paragraphs_structured(start=first, limit=last - first + 1) - def _resolve_validated_ref(self, ref: str) -> tuple[int, Element]: + def _resolve_validated_ref(self, ref: str) -> tuple[int, list[Element]]: """Parse ``ref``, bounds-check its index, and verify its hash. Shared validation for the ref-taking read methods (:meth:`get_paragraph_location`, :meth:`context`). Returns: - ``(index, paragraph_element)`` — the ref's 1-based index and the - ``w:p`` element it resolves to. + ``(index, paragraphs)`` — the ref's 1-based index and the full + document paragraph list it was validated against, so callers that + need the elements don't re-query the DOM. Raises: ValueError: If ``ref`` has an invalid format. @@ -595,7 +600,7 @@ def _resolve_validated_ref(self, ref: str) -> tuple[int, Element]: if len(tm.text) > 80: preview += "..." raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview) - return parsed.index, p + return parsed.index, paragraphs def _style_maps(self) -> tuple[dict[str, int], dict[str, ListItem]]: """Outline-level and numbering maps defined by paragraph styles. @@ -687,8 +692,8 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation: print(f"section {loc.section}") """ self._ensure_open() - index, p = self._resolve_validated_ref(ref) - paragraphs = self._document_editor.dom.getElementsByTagName("w:p") + index, paragraphs = self._resolve_validated_ref(ref) + p = paragraphs[index - 1] style_outlines, style_numbering = self._style_maps() heading_path = _compute_heading_paths(paragraphs[:index], style_outlines)[-1] section = _compute_section_indexes(paragraphs[:index])[-1] diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md index b2fa75c..50604eb 100644 --- a/skills/docx/SKILL.md +++ b/skills/docx/SKILL.md @@ -247,7 +247,7 @@ doc.batch_edit(ops) # order as above (an edit never shifts the matches before it; ascending # mis-targets, and descending is not valid for self-overlapping search strings # like "aa" in "aaaa"). -# SearchResults print compactly — SearchResult(P3#a7b2 occ=1 '30 days') — so +# SearchResults print compactly — SearchResult(P3#a7b2 occ=0 '30 days') — so # printing a whole find_all() list is cheap. Each result also carries # paragraph_index (the int inside paragraph_ref); never string-parse refs. @@ -643,7 +643,7 @@ everything = doc.list_paragraphs(limit=None) # uncapped, never a notice refs_only = doc.list_paragraphs(max_chars=0) # "P1#a7b2", no preview ``` -(`list_paragraphs_structured()` has the same 200-record default cap but appends **no notice** — every entry stays a typed `ParagraphInfo`. Detect truncation by comparing `len(result)` with `paragraph_count()`, or pass `limit=None`.) +(`list_paragraphs_structured()` has the same 200-record default cap but appends **no notice** — every entry stays a typed `ParagraphInfo`. Detect truncation by checking whether the last record's `index` is still below `paragraph_count()` (robust for any `start`), or pass `limit=None`.) The `paragraph` argument is **required** for all edit methods. If the paragraph content has changed since you called `list_paragraphs()`, a `HashMismatchError` is raised — preventing edits to the wrong location. diff --git a/tests/test_paragraph_hash.py b/tests/test_paragraph_hash.py index 8a3b031..4ea6dc3 100644 --- a/tests/test_paragraph_hash.py +++ b/tests/test_paragraph_hash.py @@ -200,7 +200,8 @@ def test_pagination_slice(self, temp_docx): assert page[0].startswith("P2#") assert page[1].startswith("P3#") assert page[:2] == full[1:3] - assert page[2] == "... 1 more paragraphs; use start=4 or limit=None" + # Singular case: the notice noun pluralizes with the count. + assert page[2] == "... 1 more paragraph; use start=4 or limit=None" finally: doc.close() From 5d602bd0fc07e8440dc483c18bfdc5da8711710c Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Fri, 17 Jul 2026 04:20:08 +0200 Subject: [PATCH 3/3] =?UTF-8?q?polish:=20review=20round=202=20=E2=80=94=20?= =?UTF-8?q?elide=20long=20matched=20text=20in=20SearchResult=20repr,=20com?= =?UTF-8?q?plete=20limit=3DNone=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - track_changes.py: repr elides matched text past 60 chars (display only; the text attribute keeps the full match) — sentence-length search anchors otherwise blow up the exact token budget the compact repr exists for. - SKILL.md refs_only example and docx-session exec help example now pass limit=None like their siblings, so no entry can be a truncation notice. - README: merge the duplicated bare list_paragraphs() intro line into the pagination example. - docs/api.md: document the repr elision. --- README.md | 8 +++----- docs/api.md | 4 +++- docx_editor/session.py | 2 +- docx_editor/track_changes.py | 7 +++++-- skills/docx/SKILL.md | 2 +- tests/test_find_all.py | 15 +++++++++++++++ 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 68ea548..33fc6cb 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,9 @@ with Document.open("reviewed.docx", author="Editor") as doc: # Get visible text (inserted text included, deleted excluded) text = doc.get_visible_text() - # List paragraphs to find hash-anchored references - refs = doc.list_paragraphs() - - # Large documents: a bare call returns at most 200 paragraphs, ending - # with a "... N more paragraphs; use start=201 or limit=None" notice. + # List paragraphs to find hash-anchored references. Large documents: + # a bare call returns at most 200 paragraphs, ending with a + # "... N more paragraphs; use start=201 or limit=None" notice. # Refs stay globally indexed across pages (page 2 starts at P201, not P1). page1 = doc.list_paragraphs() # up to P200, then the notice page2 = doc.list_paragraphs(start=201) # next page, per the notice diff --git a/docs/api.md b/docs/api.md index 3487716..21e8089 100644 --- a/docs/api.md +++ b/docs/api.md @@ -977,7 +977,9 @@ stale once that paragraph is edited. `repr()`/`str()` are compact one-liners — `SearchResult(P3#a7b2 occ=0 '30 days')`, with a trailing `spans_rev` marker when `spans_revision` is true — so printing a whole `find_all()` list stays -cheap. Every field remains accessible as an attribute. +cheap. Matched text longer than 60 characters is elided with `...` in the +display only; every field, including the full `text`, remains accessible as +an attribute. ### Example diff --git a/docx_editor/session.py b/docx_editor/session.py index 5afce53..1609aa5 100644 --- a/docx_editor/session.py +++ b/docx_editor/session.py @@ -523,7 +523,7 @@ def _read_code(arg: str) -> str: route for code starting with '-' (argparse would read a bare "-x" as a flag): docx-session exec - <<'PY' -for p in doc.list_paragraphs(): +for p in doc.list_paragraphs(limit=None): print(p) PY""" diff --git a/docx_editor/track_changes.py b/docx_editor/track_changes.py index cd81a80..e59713b 100644 --- a/docx_editor/track_changes.py +++ b/docx_editor/track_changes.py @@ -264,7 +264,9 @@ class SearchResult: ``repr()``/``str()`` are compact one-liners (``SearchResult(P3#a7b2 occ=0 '30 days')``) so printing a list of results - stays cheap; every field remains accessible as an attribute. + stays cheap; matched text longer than 60 characters is elided with + ``"..."`` in the display only. Every field — including the full ``text`` + — remains accessible as an attribute. """ start: int # Start offset in the paragraph's visible text @@ -276,8 +278,9 @@ class SearchResult: paragraph_index: int # 1-based index of the containing paragraph (same as in paragraph_ref) def __repr__(self) -> str: + text = self.text if len(self.text) <= 60 else self.text[:57] + "..." spans = " spans_rev" if self.spans_revision else "" - return f"SearchResult({self.paragraph_ref} occ={self.paragraph_occurrence} {self.text!r}{spans})" + return f"SearchResult({self.paragraph_ref} occ={self.paragraph_occurrence} {text!r}{spans})" @dataclass(frozen=True) diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md index 50604eb..cd09fb7 100644 --- a/skills/docx/SKILL.md +++ b/skills/docx/SKILL.md @@ -640,7 +640,7 @@ page1 = doc.list_paragraphs() # up to P200, then "... N m page2 = doc.list_paragraphs(start=201) # next page, per the notice refs = [e for e in page1 if not e.startswith("...")] # drop the notice line everything = doc.list_paragraphs(limit=None) # uncapped, never a notice -refs_only = doc.list_paragraphs(max_chars=0) # "P1#a7b2", no preview +refs_only = doc.list_paragraphs(max_chars=0, limit=None) # every "P1#a7b2", no preview, no notice ``` (`list_paragraphs_structured()` has the same 200-record default cap but appends **no notice** — every entry stays a typed `ParagraphInfo`. Detect truncation by checking whether the last record's `index` is still below `paragraph_count()` (robust for any `start`), or pass `limit=None`.) diff --git a/tests/test_find_all.py b/tests/test_find_all.py index d12e540..f101d66 100644 --- a/tests/test_find_all.py +++ b/tests/test_find_all.py @@ -192,6 +192,21 @@ def test_repr_spans_rev_marker_only_when_spanning(self, multi_para_doc): assert plain.spans_revision is False assert "spans_rev" not in repr(plain) + def test_repr_elides_long_matched_text(self): + # Sentence-length search anchors are a documented pattern; the repr + # elides the display at 60 chars while the attribute keeps full text. + r = SearchResult( + start=0, + end=70, + text="x" * 70, + paragraph_ref="P1#abcd", + paragraph_occurrence=0, + spans_revision=False, + paragraph_index=1, + ) + assert repr(r) == f"SearchResult(P1#abcd occ=0 '{'x' * 57}...')" + assert r.text == "x" * 70 + class TestFindAllScoped: def test_scoped_returns_only_that_paragraphs_hits(self, multi_para_doc):