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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ with Document.open("reviewed.docx", author="Editor") as doc:
if match and match.spans_revision:
print("Text spans a revision boundary")

# Replace works even across revision boundaries
# Replace works even across revision boundaries. Instead of a ref, you can
# pass the match itself — it carries the text, paragraph and occurrence:
# doc.replace(match, "Goal: To")
doc.replace("Aim: To", "Goal: To", paragraph="P1#a7b2")

doc.save()
Expand Down
263 changes: 212 additions & 51 deletions docs/api.md

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ with Document.open("contract.docx", author="Legal Team") as doc:
doc.save()
```

If the source `.docx` was modified outside this library since the workspace was created, `Document.open()` raises `WorkspaceSyncError` instead of silently discarding the workspace. The same error is raised when a leftover workspace holds unsaved changes from a previous session — for example, one that saved to a different path (or whose save failed) and never called `close()` — since adopting it would carry those edits into the new session. The error message includes the workspace path. Pass `force_recreate=True` to acknowledge the divergence and re-unpack from the current source:
If the source `.docx` was modified outside this library since the workspace was created, `Document.open()` raises `WorkspaceSyncError` instead of silently discarding the workspace. The same error is raised when a leftover workspace holds unsaved changes from a previous session — for example, one that saved to a different path (or whose save failed) and never called `close()` — since adopting it would carry those edits into the new session. The error message includes the workspace path. Pass `force_recreate=True` to acknowledge the divergence and re-unpack from the current source (after a crashed script, one `Document.discard_workspace("contract.docx")` beats carrying the flag on every open — it also clears a lock the dead process left behind):

```python
doc = Document.open("contract.docx", force_recreate=True)
Expand Down Expand Up @@ -74,6 +74,18 @@ Use the `P{index}#{hash}` part as the `paragraph=` argument. Edit methods return

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`.

Better still, let a search do that bookkeeping: those five methods (and the `EditOperation` constructors) accept a `SearchResult` in place of the target text, taking the paragraph and occurrence from the match.

```python
with Document.open("contract.docx") as doc:
if match := doc.find_text("30 days"): # or find_all(...)[1] for the 2nd
doc.replace(match, "60 days") # no paragraph=, no occurrence=
```

Check the result first: `find_text()` returns `None` when there is no match, and
passing that `None` as a target raises `ValueError` (`CommentError` from
`add_comment`) rather than quietly doing nothing.

## Track Changes

### Replace Text
Expand Down
320 changes: 259 additions & 61 deletions docx_editor/document.py

Large diffs are not rendered by default.

87 changes: 78 additions & 9 deletions docx_editor/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,20 +345,39 @@ def _strip_internal_paths(text: str) -> str:

@dataclass
class ExecResult:
"""Outcome of one exec_code() call against the session kernel."""
"""Outcome of one exec_code() call against the session kernel.

``started`` records whether the kernel began executing this request at all,
which disambiguates two statuses:

- ``"timeout"``: with ``started=True`` your code ran and was still running
when the clock ran out (raise ``timeout``, or make the code faster); with
``started=False`` the request never left the queue, because the kernel was
still busy with an earlier command — nothing of yours executed, so wait or
check what is hogging the session.
- ``"ok"`` with ``started=False``: the kernel **discarded** the request
without running it. ipykernel aborts everything still queued behind a
command that raises, so a request sent while an earlier one was failing
comes back clean and empty having done nothing. Re-send it.

An ``"error"`` result, and any ``"ok"`` with ``started=True``, did execute.
"""

status: ExecStatus
stdout: str = ""
stderr: str = ""
result: str | None = None # repr of the last expression, if any
traceback: str | None = None # ANSI-stripped traceback when status == "error"
started: bool = False # True once the kernel began executing this request


def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeout: float = 120.0) -> ExecResult:
"""Execute code in the session kernel and collect its output.

If the kernel is busy, the request queues behind the running one; `timeout`
covers the whole wait.
covers the whole wait. A timeout therefore has two flavours, told apart by
the result's ``started`` flag: your own code ran too long (True), or it never
got out of the queue (False).

Args:
code: Python source to execute in the session
Expand All @@ -367,7 +386,10 @@ def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou

Returns:
ExecResult with status "ok", "error", "timeout", or "dead" (the kernel
died mid-execution — its state is lost).
died mid-execution — its state is lost), and ``started`` telling whether
the kernel began executing this request at all. Check ``started`` on an
"ok" result too: ``ok`` with ``started=False`` means the kernel aborted
the request behind a failing predecessor and ran nothing.

Raises:
FileNotFoundError: If no session connection file exists.
Expand Down Expand Up @@ -398,14 +420,20 @@ def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou
result: str | None = None
traceback: str | None = None
status: ExecStatus = "ok"
started = False
deadline = time.monotonic() + timeout
last_activity = time.monotonic()

while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
final: ExecStatus = "dead" if _kernel_dead(kc, connection_file) else "timeout"
return ExecResult(status=final, stdout="".join(stdout_parts), stderr="".join(stderr_parts))
return ExecResult(
status=final,
stdout="".join(stdout_parts),
stderr="".join(stderr_parts),
started=started,
)
try:
msg = kc.get_iopub_msg(timeout=min(remaining, 1.0))
except Empty:
Expand All @@ -414,7 +442,12 @@ def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou
if time.monotonic() - last_activity < _SILENCE_PROBE_AFTER:
continue
if _kernel_dead(kc, connection_file):
return ExecResult(status="dead", stdout="".join(stdout_parts), stderr="".join(stderr_parts))
return ExecResult(
status="dead",
stdout="".join(stdout_parts),
stderr="".join(stderr_parts),
started=started,
)
last_activity = time.monotonic()
continue
# Any iopub traffic proves the kernel is alive, whoever it belongs to.
Expand All @@ -424,7 +457,12 @@ def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou

msg_type = msg["msg_type"]
content = msg["content"]
if msg_type == "stream":
if msg_type == "execute_input":
# The kernel broadcasts this the moment it starts executing OUR
# request — the only reliable "left the queue" signal, since a
# queued request is otherwise indistinguishable from a slow one.
started = True
elif msg_type == "stream":
target = stdout_parts if content["name"] == "stdout" else stderr_parts
target.append(content["text"])
elif msg_type in ("execute_result", "display_data"):
Expand All @@ -441,6 +479,7 @@ def exec_code(code: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou
stderr="".join(stderr_parts),
result=result,
traceback=traceback,
started=started,
)
finally:
kc.stop_channels()
Expand Down Expand Up @@ -529,6 +568,7 @@ class EvalResult:
stderr: str = ""
traceback: str | None = None
error: dict[str, Any] | None = None # {"type", "message", <structured fields>} when the expression raised
started: bool = False # as in ExecResult: False on a timeout means the request never left the queue
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def eval_code(expr: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeout: float = 120.0) -> EvalResult:
Expand Down Expand Up @@ -564,8 +604,21 @@ def eval_code(expr: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou
"""
res = exec_code(_EVAL_TEMPLATE % (repr(expr),), connection_file=connection_file, timeout=timeout)
if res.status != "ok":
return EvalResult(status=res.status, stdout=res.stdout, stderr=res.stderr, traceback=res.traceback)
return EvalResult(
status=res.status,
stdout=res.stdout,
stderr=res.stderr,
traceback=res.traceback,
started=res.started,
)
if res.result is None:
if not res.started:
# Not a transport fault: the kernel discarded the request without
# running it (see ExecResult.started), so there is no reply to decode.
raise SessionError(
"The kernel discarded this request without running it — it was queued behind a command "
"that raised. Nothing was evaluated; re-send it."
)
raise SessionError("eval transport failed: kernel returned no result for the eval wrapper")
try:
payload = json.loads(ast.literal_eval(res.result))
Expand All @@ -578,13 +631,15 @@ def eval_code(expr: str, connection_file: Path = DEFAULT_CONNECTION_FILE, timeou
stderr=res.stderr,
traceback=_strip_internal_paths(payload["traceback"]),
error=payload["error"],
started=res.started,
)
return EvalResult(
status="ok",
value=payload["value"],
serialized=payload["serialized"],
stdout=res.stdout,
stderr=res.stderr,
started=res.started,
)


Expand Down Expand Up @@ -738,11 +793,25 @@ def _run(args: argparse.Namespace) -> int:
print(_KERNEL_DIED_MSG, file=sys.stderr)
return EXIT_KERNEL_DEAD
if res.status == "timeout":
if res.started:
message = f"Timed out after {args.timeout}s (kernel still running; the command may still finish)."
else:
message = (
f"Timed out after {args.timeout}s while still queued — your code never started. The kernel is "
"busy with an earlier command; wait for it, raise --timeout, or check 'docx-session status'."
)
print(message, file=sys.stderr)
return EXIT_TIMEOUT
if not res.started:
# "ok" but never dequeued: the kernel discards everything queued behind
# a command that raises, so this returned clean without running. Exit
# code stays EXIT_OK (changing it is a contract change), which is
# exactly why the warning has to be loud.
print(
f"Timed out after {args.timeout}s (kernel still running; the command may still finish).",
"Warning: the kernel discarded this request without running it — it was queued behind a "
"command that raised. Nothing was executed; re-send it.",
file=sys.stderr,
)
return EXIT_TIMEOUT
return EXIT_OK


Expand Down
Loading
Loading