Metadata-Version: 2.5
Name: docx-scalpel
Version: 0.1.0
Summary: Anchor-addressed DOCX editing for LLM agents — a thin client over Docxodus' DocxSession.
Project-URL: Homepage, https://github.com/JSv4/Docxodus
Project-URL: Repository, https://github.com/JSv4/Docxodus
Project-URL: Issues, https://github.com/JSv4/Docxodus/issues
Author: Docxodus contributors
License: MIT
Keywords: agent,diff,document-comparison,document-editing,docx,llm,markdown,ooxml,openxml,redline,tracked-changes,word
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Classifier: Topic :: Text Processing :: Markup
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: test
Requires-Dist: pytest>=7.4; extra == 'test'
Description-Content-Type: text/markdown

# docx-scalpel

**Anchor-addressed DOCX editing for LLM agents — a thin client over [Docxodus](https://github.com/JSv4/Docxodus)' `DocxSession`.**

`docx-scalpel` exposes Docxodus' stateful DOCX editor over a long-running .NET subprocess (`docxodus-pyhost`). The session lives in the host's memory until you explicitly release it, so an LLM agent can issue dozens of small edits against one document without paying the OOXML parse + Unid annotation + projection cost on every call.

> **Status:** Beta. Wheels ship a bundled `docxodus-pyhost` for linux-x64, linux-arm64, osx-arm64, and win-x64; any other platform installs from the sdist and needs a host of its own (see below).

## Installation

```bash
pip install docx-scalpel
```

That resolves a wheel on linux-x64, linux-arm64, osx-arm64, or win-x64 — each carrying a self-contained `docxodus-pyhost` built from the same commit as the release, so there's no .NET runtime to install and no version to pin.

Source installs (`pip install` of the sdist, or `pip install -e .` from a dev clone) don't include a bundled host. Set `DOCXODUS_HOST=/path/to/docxodus-pyhost` to point at one you built, or run `dotnet build tools/python-host/pyhost.csproj` inside a Docxodus monorepo clone — the locator auto-discovers it.

## Quick start

```python
from docx_scalpel import open_session, FormatOp, Position

with open("contract.docx", "rb") as f:
    docx_bytes = f.read()

with open_session(docx_bytes) as session:
    # Walk template placeholders and fill them. The picker returns a string to
    # replace, or None to skip. fill_placeholders handles reverse-offset
    # ordering, $-prefix preservation, and multi-pass nested-bracket convergence
    # in one call.
    result = session.fill_placeholders(lambda p: "filled value")
    print(f"filled {result.filled} placeholders in {result.passes} passes")

    # Add a heading after the first body paragraph.
    proj = session.project()
    first_p = next(
        t for t in proj.anchor_index.values()
        if t.kind in ("p", "h") and t.scope == "body"
    )
    session.insert_paragraph(first_p.id, Position.AFTER, "## Reviewed by counsel")

    # Bold the first 8 characters of that paragraph.
    session.apply_format_by_substring(first_p.id, "Reviewed", FormatOp(bold=True))

    new_bytes = session.save()

with open("filled.docx", "wb") as f:
    f.write(new_bytes)
```

The `with` block is the documented lifecycle path — it calls `session.close()` on the way out, which releases the session from the host's `SessionRegistry`. A `__del__` finalizer is a fallback for forgotten sessions but should not be relied on; interpreter shutdown may skip it.

## Why a subprocess?

`DocxSession` holds a parsed `WordprocessingDocument`, an `AnchorIndex` of Unid-stamped block-level targets, a cached `MarkdownProjection`, and a bounded `UndoRing` of per-part XDocument snapshots. Recreating it costs tens of ms on small docs and seconds on large ones. The subprocess model lets one Python process drive many sessions across many calls, all in one host's memory, until you decide to close them.

Architecture:

```
Python process                 docxodus-pyhost (.NET 10)
─────────────                  ──────────────────────────
DocxSession  ──NDJSON──>       Dispatcher
                               │
                               ▼
                               DocxSessionOps
                               │
                               ▼
                               SessionRegistry (handle → DocxSession)
```

One host per Python process. Many sessions inside the host. `atexit` sends `shutdown` and (if the host doesn't comply) terminates / kills.

Full design + wire-protocol spec: [`docs/architecture/python_docxodus.md`](../docs/architecture/python_docxodus.md).
Delta-spec for the `docx-scalpel` rebrand: [`docs/superpowers/specs/2026-05-26-docx-scalpel-design.md`](../docs/superpowers/specs/2026-05-26-docx-scalpel-design.md).

## Development

### Build the host binary (one-time)

```bash
# From the Docxodus repo root:
dotnet build tools/python-host/pyhost.csproj -c Release
```

This produces `tools/python-host/bin/Release/net10.0/docxodus-pyhost`. `_host_locator.py` discovers it automatically when you `pip install -e .` from a monorepo clone.

For non-monorepo development, set `DOCXODUS_HOST=/path/to/docxodus-pyhost` to override the discovery path.

A `dotnet build` host is framework-dependent, so it needs the .NET 10 runtime at launch. If your *system* `dotnet` is older and .NET 10 lives elsewhere (e.g. `~/.dotnet`), the host will exit with `You must install or update .NET to run this application`; export `DOTNET_ROOT` to point at the newer install. Released wheels are unaffected — they bundle a self-contained host with no runtime lookup.

```bash
export DOTNET_ROOT="$HOME/.dotnet"
```

### Editable install + tests

```bash
cd python
python -m venv .venv
.venv/bin/pip install -e .[test]
.venv/bin/pytest -v
```

### Test layout

- `tests/test_smoke.py` — end-to-end mirror of `Docxodus.Tests/DocxSessionSmokeTest.cs`. v1 acceptance gate.
- `tests/test_lifecycle.py` — proves session persistence, idempotent close, singleton host, finalizer fallback.

Tests share the Docxodus monorepo's `TestFiles/` corpus so divergence between Python and .NET on identical inputs is detectable.

## API surface

The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` as a snake-case method:

| Tier | Methods |
|---|---|
| **Lifecycle** | `save`, `close`, `undo`, `redo`, `to_html` |
| **Projection** | `project`, `project_anchor` |
| **Discovery** | `grep`, `grep_cross_block`, `find_placeholders`, `find_by_text`, `find_all_by_text`, `find_by_regex`, `find_by_kind`, `find_by_annotation`, `find_by_label`, `find_by_bookmark`, `list_annotations`, `exists`, `get_anchor_info`, `get_anchor_infos`, `get_edit_summary`, `remaining_placeholders`, `get_diff` |
| **Inspection** | `get_block_metadata`, `get_block_metadatas`, `get_list_membership`, `get_section_info` |
| **A: text mutations** | `replace_text`, `replace_text_range`, `replace_text_at_span`, `replace_inner`, `replace_match`, `delete_block`, `move_block`, `delete_range`, `delete_section` |
| **B: structural** | `insert_paragraph`, `split_paragraph`, `merge_paragraphs` |
| **B: headers/footers/page numbers** | `set_header_text`, `set_footer_text`, `ensure_header_footer_visible`, `insert_page_number_field`, `set_page_numbering`, `clear_page_numbering` |
| **B: footnotes/endnotes** | `insert_footnote`, `insert_endnote` |
| **B: native comments** | `add_comment`, `add_comment_to_revision`, `add_comment_reply`, `update_comment`, `set_comment_resolved`, `remove_comment`, `list_comments` |
| **C: formatting** | `apply_format`, `apply_format_by_substring`, `set_paragraph_style`, `set_paragraph_format`, `set_list_level`, `remove_list_membership`, `apply_list_format`, `apply_list_format_range`, `set_list_start_override`, `clear_list_start_override` |
| **D: tables** | `replace_cell_content` |
| **D: tracked changes** | `set_tracked_changes`, `set_revision_author`, `list_revisions`, `accept_revision`, `reject_revision` |
| **E: annotations** | `add_annotation`, `remove_annotation`, `update_annotation`, `move_annotation` |
| **Raw XML** | `session.raw.get_xml`, `session.raw.insert_xml`, `session.raw.replace_xml` |

Every mutation method returns an `EditResult` envelope — transport-level failures raise `DocxodusTransportError`, but a business outcome (`anchor_not_found`, `malformed_markdown`, etc.) returns `EditResult(success=False, error=EditError(...))`. **Never** an exception across the API boundary.

### Stateless functions

Alongside the session API, the package exposes stateless one-shot functions at the module root — no session handle, they take DOCX bytes in and return bytes / data out:

| Function | Signature | Returns |
|---|---|---|
| `convert_docx_to_html` | `(data, options=None)` | HTML `str` |
| `docx_diff_compare` | `(left, right, settings=None)` | redlined DOCX `bytes` (native `w:ins`/`w:del`/`w:moveFrom`/`w:moveTo`/`w:rPrChange` markup) |
| `docx_diff_get_revisions` | `(left, right, settings=None)` | `tuple[DocxDiffRevision, ...]` |
| `docx_diff_get_edit_script` | `(left, right, settings=None)` | edit-script JSON `str` |
| `docx_diff_accept_revisions` | `(redline)` | `bytes` — accept every tracked change (≡ the right side of the diff) |
| `docx_diff_reject_revisions` | `(redline)` | `bytes` — reject every tracked change (≡ the left side) |
| `docx_diff_consolidate` | `(base, reviewers, settings=None)` | multi-author redlined DOCX `bytes` — merge N `DocxDiffReviewer` diffs against one shared base |
| `docx_diff_get_conflicts` | `(base, reviewers, settings=None)` | `tuple[DocxDiffConflict, ...]` |
| `docx_diff_get_consolidated_revisions` | `(base, reviewers, settings=None)` | `tuple[DocxDiffConsolidatedRevision, ...]` |
| `docx_diff_get_consolidated_edit_script` | `(base, reviewers, settings=None)` | edit-script JSON `str` |

The `docx_diff_*` family is a thin client over Docxodus' `DocxDiff` IR diff engine. Tune pairwise comparisons with `DocxDiffSettings` and N-way consolidation with `DocxDiffConsolidateSettings` (whose `conflict_resolution` takes a `ConflictResolution` value). `DetectMoves`/format-change tracking, header/footer comparison, and per-reviewer attribution all round-trip through these calls.

```python
from docx_scalpel import docx_diff_compare, docx_diff_get_revisions, DocxDiffSettings

with open("v1.docx", "rb") as f: left = f.read()
with open("v2.docx", "rb") as f: right = f.read()

redline = docx_diff_compare(left, right, DocxDiffSettings(author_for_revisions="Reviewer"))
for rev in docx_diff_get_revisions(left, right):
    print(rev.type, rev.text)
```

## License

MIT. Built on top of [Docxodus](https://github.com/JSv4/Docxodus), which is itself a fork of Open-Xml-PowerTools.
