Skip to content

feat: NDJSON elements-file mode for partition (0.46.0) - #347

Open
badGarnet wants to merge 5 commits into
mainfrom
feat/ndjson-elements-file
Open

feat: NDJSON elements-file mode for partition (0.46.0)#347
badGarnet wants to merge 5 commits into
mainfrom
feat/ndjson-elements-file

Conversation

@badGarnet

@badGarnet badGarnet commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an opt-in NDJSON response mode to partition() that returns elements as a path to a file on disk instead of a parsed list, and ships it as 0.46.0.

from unstructured_client.general import PartitionAcceptEnum

res = client.general.partition(
    request=req,
    accept_header_override=PartitionAcceptEnum.APPLICATION_X_NDJSON,
)

try:
    with open(res.elements_file, encoding="utf-8") as f:
        for line in f:
            element = json.loads(line)
            ...
finally:
    os.unlink(res.elements_file)

PartitionResponse.elements_file is set instead of PartitionResponse.elements. The caller owns the file and must delete it. Requesting application/json remains the default and is entirely unchanged.

Why

On the split-PDF path the SDK rebuilt the whole document in memory in order to return it: a list per chunk, a flattened list, a json.dumps blob in create_response, and then the SDK's re-parse of that blob — four copies live at once, with the serialization step dominating peak usage. For documents with large metadata.image_base64 payloads this is the difference between a job completing and being OOM-killed.

In the new mode the per-chunk temp files are concatenated on disk and never parsed, so peak memory is roughly one chunk rather than the whole document.

How

  • combine_chunk_files_to_ndjson concatenates chunk files on disk. Each chunk is sniffed for its first non-whitespace character, so a server returning application/json still works; chunks that are already NDJSON are copied through without parsing.
  • ndjson_mode depends only on the Accept header, never on split_pdf_cache_tmp_data. Those are set by different parties, so gating on both let them disagree — the server would return NDJSON while the hook took the JSON path and res.json() raised on a body this client had itself requested.
  • Both caching modes are handled. A cached chunk contributes its existing temp-file path; an uncached one spills its body verbatim and then releases it, since every response is retained in api_successful_responses and leaving _content set would keep the document resident regardless.
  • The combined file is deliberately written outside the operation's TemporaryDirectory, which _clear_operation removes as soon as after_success returns.

Temp-file ownership

Everything this path creates is accounted for:

  • Spilled chunk bodies are written inside the operation's temp directory and unlinked once combined.
  • The combined file is deleted when a chunk failure means it is never handed back to the caller.
  • Recombination writes to a staging file that is atomically renamed into place only on success, so a malformed chunk cannot orphan a partial file.
  • No combined file is created at all when every chunk failed.

Security

The elements-file marker is an httpx response extension, not a response header. Extensions are populated by the transport, so a remote server cannot set the key. A header would be wire-controlled, and since callers are documented to open elements_file and then delete it, that would hand a hostile server an arbitrary local file to destroy. A real server body is always copied to a file this client creates.

Regeneration

elements_file is client-side only and can never come from the OpenAPI spec, so a regeneration would silently drop it. Both general.py and models/operations/partition.py are now in .genignore, and test_regeneration_guards.py fails if either entry is lost.

Known limitation

For unsplit inputs (non-PDF, or split_pdf_page=False) the response body is still fully read into memory before being written to disk, so peak is 2x the body rather than bounded. This does not affect the split-PDF path that motivated the change, where the SDK only ever sees an empty dummy response or the small synthetic path response. Fixing it means stream=True for NDJSON requests, which makes raw_response.content raise on the returned closed response — a user-visible change worth its own review. Tracked separately.

Testing

  • New _test_unstructured_client/unit/test_ndjson_elements_file.py — recombination across JSON-array / NDJSON / mixed chunk formats, order preservation, byte-exact payload round-trip, non-ASCII, temp-file lifecycle on success and failure, and regression guards for the header-spoofing and partial-output defects.
  • 235 unit tests and 64 contract tests pass; pylint 10.00/10; mypy clean.

🤖 Generated with Claude Code

Review in cubic

badGarnet and others added 3 commits August 1, 2026 13:04
On the split-PDF path the SDK rebuilt the whole document in memory to return it:
a list per chunk, a flattened list, a json.dumps blob in create_response, and the
SDK's re-parse of that blob -- four copies live at once, with the serialization
step dominating peak usage on large documents.

Passing accept_header_override=PartitionAcceptEnum.APPLICATION_X_NDJSON now
returns PartitionResponse.elements_file -- a path to an NDJSON file, one element
per line -- instead of PartitionResponse.elements. The per-chunk temp files are
concatenated on disk and never parsed, so peak memory is roughly one chunk rather
than the whole document.

Chunk files are sniffed for their first non-whitespace character, so a server
returning application/json still works; NDJSON chunks are copied through
untouched. Requesting application/json remains the default and is unchanged.

The caller owns the returned file and must delete it. It is deliberately written
outside the operation's TemporaryDirectory, which _clear_operation removes as soon
as after_success returns.

ndjson_mode depends only on the Accept header, never on split_pdf_cache_tmp_data.
Those are set by different parties, so gating on both let them disagree: the
server would return NDJSON while the hook took the JSON path and res.json() raised
on a body this client had itself requested. Both caching modes are handled -- a
cached chunk contributes its existing temp-file path, an uncached one spills its
body verbatim and then releases it, since every response is retained in
api_successful_responses and leaving _content set would keep the document resident
regardless.

general.py and models/operations/partition.py are both .genignore'd: elements_file
is client-side only and can never come from the OpenAPI spec, so a regeneration
would silently drop it. test_regeneration_guards.py fails if either entry is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found in review of the elements-file mode.

The elements-file marker was an `x-unstructured-elements-file` response header, and
any response carrying it was trusted as an SDK-created path. Headers come off the
wire, so a server could name an arbitrary local file -- and callers are documented
to open `elements_file` and then delete it, making this an arbitrary-file delete
rather than just a disclosure. The marker is now an httpx response extension, which
is populated by the transport and cannot be set remotely; a real server body is
always copied to a file this client creates.

Recombination also wrote straight to its final UUID path while recording that path
only on success, so a malformed chunk left a partial file behind under a name
nothing owned -- the combined file is deliberately outside the operation's
TemporaryDirectory, so nothing else cleaned it up. It now writes to a staging file
renamed into place atomically, and unlinks it on any exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships the NDJSON elements-file mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread src/unstructured_client/general.py Outdated
Comment thread docs/models/operations/partitionresponse.md
Comment thread src/unstructured_client/_hooks/custom/request_utils.py
Comment thread src/unstructured_client/_hooks/custom/split_pdf_hook.py Outdated
Comment thread README.md Outdated
Comment thread README.md
Comment thread _test_unstructured_client/unit/test_ndjson_elements_file.py Outdated
Addresses review findings on the elements-file path.

Orphaned files on failure. Both `_ndjson_elements_file` helpers create their
destination with delete=False, so a body that raises or is cancelled partway
through left the partial copy behind; they now unlink it and re-raise.
`write_chunk_body_to_temp` had the same shape -- the caller only registers the
path for cleanup once the function returns, so a failed write orphaned the file,
and a full disk is exactly the failure that repeats.

Cancellation race. Recombination runs in a worker thread that cancellation cannot
interrupt, so `_clear_operation` could tear the operation down while it was still
running; publishing the path afterwards resurrected a cleared dict entry and
orphaned the file. Publishing is now gated on the operation still being live,
under a lock that `_clear_operation` also takes when dropping
`pending_operation_ids`. Ownership is explicit either way: the success path claims
the path out of `ndjson_output_path`, so anything still recorded at teardown was
never delivered and is safe to delete.

Docs regeneration. docs/models/operations/partitionresponse.md is generated and
tracked in gen.lock, and the generation workflow runs on a daily cron, so the
elements_file row would have been dropped within a day of merging. Added to
.genignore alongside the model, and the regeneration guard now asserts the row.

README. Noted that the memory saving applies to the split-PDF path -- unsplit
inputs still buffer the body -- so the caveat is visible where the feature is
advertised rather than only in the PR. The example's cleanup used a bare unlink in
a finally, which would mask a failure to open the file with FileNotFoundError; it
now uses Path.unlink(missing_ok=True).

The spilled-body regression guard asserted against `_content` it had assigned
itself, so it could not fail if the hook stopped releasing the body. It now drives
`_elements_from_task_responses`, and was confirmed to fail with the release
removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/unstructured_client/_hooks/custom/request_utils.py">

<violation number="1" location="src/unstructured_client/_hooks/custom/request_utils.py:363">
P3: The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

The path to the spilled file. The caller owns deleting it.
"""
fd, path = tempfile.mkstemp(suffix=".ndjson", dir=dir_ or tempfile.gettempdir())
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/unstructured_client/_hooks/custom/request_utils.py, line 363:

<comment>The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.</comment>

<file context>
@@ -360,8 +360,18 @@ def write_chunk_body_to_temp(response: httpx.Response, dir_: Optional[str] = Non
     fd, path = tempfile.mkstemp(suffix=".ndjson", dir=dir_ or tempfile.gettempdir())
-    with os.fdopen(fd, "wb") as f:
-        f.write(response.content)
+    try:
+        with os.fdopen(fd, "wb") as f:
+            f.write(response.content)
</file context>

The unlink-on-failure paths added in the previous commit had no coverage -- the
existing tests only walked the success roundtrip, so the cleanup could have been
removed without anything going red.

Three tests, each confirmed to fail with its corresponding unlink removed:

- write_chunk_body_to_temp: os.fdopen is patched so the write raises ENOSPC. The
  wrapper still closes the real handle, so the fd is not leaked by the test itself.
- _ndjson_elements_file and its async counterpart: a response whose byte iterator
  raises partway through. tempfile.tempdir is redirected at the test's tmp_path so
  the assertion can see whether anything was left behind.

Each asserts both halves of the contract: no file survives, and the original
exception still propagates rather than being swallowed by the cleanup.

Coverage for the general.py pair was not requested in review, but those helpers
grew the same delete=False cleanup in the same commit and had the same gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="_test_unstructured_client/unit/test_ndjson_elements_file.py">

<violation number="1" location="_test_unstructured_client/unit/test_ndjson_elements_file.py:225">
P3: The orphan assertion in both copy-failure tests (`tmp_path.glob("unst_elements_*") == []`) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by `_new_elements_file()` honoring the monkeypatched global `tempfile.tempdir` (it passes no explicit `dir`). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching `_new_elements_file` or capturing `out.name`) and asserting it was under tmp_path, so the cleanup check can't become vacuous.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

The destination is created with delete=False so it can outlive the helper, which is
exactly what makes an interrupted copy leak.
"""
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The orphan assertion in both copy-failure tests (tmp_path.glob("unst_elements_*") == []) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by _new_elements_file() honoring the monkeypatched global tempfile.tempdir (it passes no explicit dir). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching _new_elements_file or capturing out.name) and asserting it was under tmp_path, so the cleanup check can't become vacuous.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At _test_unstructured_client/unit/test_ndjson_elements_file.py, line 225:

<comment>The orphan assertion in both copy-failure tests (`tmp_path.glob("unst_elements_*") == []`) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by `_new_elements_file()` honoring the monkeypatched global `tempfile.tempdir` (it passes no explicit `dir`). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching `_new_elements_file` or capturing `out.name`) and asserting it was under tmp_path, so the cleanup check can't become vacuous.</comment>

<file context>
@@ -171,6 +178,83 @@ def test_write_chunk_body_to_temp_roundtrips(tmp_path):
+    The destination is created with delete=False so it can outlive the helper, which is
+    exactly what makes an interrupted copy leak.
+    """
+    monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
+
+    class _FailingResponse:
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant