GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — feat: NDJSON elements-file mode for partition (0.46.0) by badGarnet · Pull Request #347 · Unstructured-IO/unstructured-python-client Conversation
|
| content_type=http_res.headers.get("Content-Type") or "", | ||
| raw_response=http_res, | ||
| ) | ||
| if utils.match_response(http_res, "200", "application/x-ndjson"): |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This is the one I'd most want changed before merge.
I checked the live spec rather than the vendored copy — https://api.unstructuredapp.io/general/openapi.json, currently v1.5.69. The 200 declares application/json and text/csv only, and the string ndjson appears nowhere in the document. So the deployed server does not emit application/x-ndjson today.
Which means on the unsplit path the real server response lands here, and the application/json branch above is checked first and wins:
content-type: application/json
matches application/json -> True
matches application/x-ndjson -> False
elements gets populated and elements_file stays None, even though the caller explicitly asked for NDJSON.
Worth stressing how wide "unsplit" is, because it isn't just non-PDFs. split_size >= page_count short-circuits in _before_request_unlocked, and with MIN_PAGES_PER_SPLIT = 2 that catches every single-page PDF, plus anything with split_pdf_page=False. So this surfaces on small inputs and will read as intermittent to whoever hits it.
Knock-on: the "Known limitation" section in the description describes a path that never executes against the current API, and the server-body branch of _ndjson_elements_file / _ndjson_elements_file_async is effectively dead code until the API ships NDJSON.
What I'd prefer: when NDJSON was requested and the server returned JSON, spill that body to a file and set elements_file anyway. That keeps the documented contract true regardless of server support, and it means the mode behaves the same way for every input. The alternative is to document that elements_file can be None and guard the README example, but then callers need two code paths for one opt-in flag.
Also worth confirming with whoever owns the API: if the server 406s on an unrecognized Accept rather than ignoring it, the unsplit path raises SDKError instead, which is a different problem again.
Related: nothing currently drives client.general.partition(accept_header_override=APPLICATION_X_NDJSON) end to end. All 24 new tests are helper- or hook-level. One mock-transport test returning application/json would have caught this.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Walking back the framing here — I called this the one I'd most want changed, and the premise underneath that is weaker than I made it sound.
I inferred "the server doesn't emit NDJSON" from the live /openapi.json. But that document is generated from the route's explicit responses={} annotations, so a runtime content-negotiation branch returning a StreamingResponse(media_type="application/x-ndjson") wouldn't necessarily show up in it. Absence there isn't absence in the server. And if you're building the client half, the reasonable assumption is that the server half is already handled — I should have asked instead of inferring from a generated artifact.
What does survive is narrower and doesn't depend on the server at all: general.py checks application/json before application/x-ndjson, so for any deployment that answers an NDJSON request with a JSON body, elements_file comes back None. That isn't hypothetical even with full support in prod — this SDK gets pointed at self-hosted and older unstructured-api images, which I'd guess is part of why combine_chunk_files_to_ndjson sniffs for [ on the chunk path in the first place. The unsplit path just doesn't have the equivalent of that sniff.
So the actual question, downgraded from "want changed" to "want confirmed": do you want the unsplit path to degrade silently to elements against an older server, or to spill the JSON body to a file so elements_file is always set? Either is defensible — I'd just rather it were a decision than an accident.
If there's a server PR landing NDJSON on /general/v0/general, point me at it and the only thing left in this thread is the README wording.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 358e21a, taking the option you preferred: elements_file is now set whether or not the server honors the header. The application/x-ndjson branch is checked first, and a JSON body is written out as NDJSON when NDJSON was requested, so callers need one code path.
I reproduced your spec finding independently before changing anything — v1.5.69, 200 declares application/json and text/csv only, ndjson absent from the whole document.
One correction to the scope, which is slightly wider than "every single-page PDF": get_optimal_split_size returns max(ceil(pages / concurrency), MIN_PAGES_PER_SPLIT) and the short-circuit is split_size >= page_count, so with the floor at 2 a two-page PDF is also sent whole. The README caveat and the PR description now both say "two pages or fewer".
I was deliberately explicit in the docstring and changelog that this conversion does not bound memory — the body is already read by the time it runs, and parsing adds the element list on top. It makes the contract uniform, nothing more. Overstating that is what produced the original problem.
On the missing coverage: three tests now drive partition() over a mock transport — server returns JSON, server returns NDJSON, and no override at all. I verified the first fails against the old dispatch. Aside for anyone writing similar tests: hold the client for the duration of the call, because sdk.py registers a weakref.finalize that closes the transport, so chaining off a temporary fails with a confusing 'NoneType' has no attribute 'build_request'.
On the 406 — I went and read the service, and it does not need confirming: this SDK cannot trigger it. The 406 NOT_ACCEPTABLE is gated on len(files) > 1. For a single file the check is skipped and the Accept value is never validated. This SDK sends one file per request — PartitionParameters.files is a single Files, and the split hook sends one chunk per request — so the unsplit path cannot raise SDKError because of it. Your caveat is real for direct multi-file API callers, just not reachable from here.
Reading that turned up something more consequential, though: the service does not negotiate response format on Accept at all. It selects the format from the output_format form field, and consults Accept only to pick multipart/mixed and for that multi-file guard. So NDJSON was never going to arrive via Accept regardless of what the spec declared — and the existing accept_header_override=TEXT_CSV does not make the server return CSV either; output_format does. I have corrected the docstring and the PR description, which both understated this as a spec omission, and filed the server-side work (add application/x-ndjson to output_format, emit it as a stream, update the spec, extend the multi-file allowlist) separately under the memory epic. Nothing in this PR depends on it.
Two follow-ups from a later automated pass, both fixed in ba3e7f4 and both touching this change: the conversion was iterating http_res.json() directly, so a {"detail": ...} body wrote its keys out as elements — it now goes through unmarshal_json_response. And detection keyed off accept_header_override alone while the split hook keys off the request header, so http_headers={"Accept": "application/x-ndjson"} produced exactly the split-vs-unsplit disagreement you were describing, on the same input. Both now read the same source.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| **You own the returned file and are responsible for deleting it.** | ||
|
|
||
| > [!NOTE] | ||
| > The memory saving applies to the split-PDF path, i.e. a PDF with `split_pdf_page=True` (the default). For unsplit inputs — a non-PDF file, or `split_pdf_page=False` — the response body is still read fully into memory before being written to disk, so peak memory can reach roughly twice the body size. You still get `elements_file` either way. |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This sentence isn't true against the current API — see the comment on general.py. For unsplit inputs the server returns application/json, the JSON branch matches first, and elements_file comes back None.
That makes the example below worse than a plain failure: open(res.elements_file) raises TypeError: expected str, bytes or os.PathLike object, not NoneType, and then the finally raises the same TypeError out of Path(None) and masks the original. So the first thing a user sees is a confusing double-TypeError from copy-pasted README code.
If the contract gets made uniform in general.py then this line is fine as written. If not, it needs to say elements_file may be None and the example needs an if res.elements_file: guard.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Softening this one too — see the reply on general.py. My claim that the server returns JSON to an NDJSON request came from the live /openapi.json, which only reflects explicit responses={} annotations, so it does not settle what the server actually does.
The narrow version still stands: if elements_file can ever come back None — an older or self-hosted deployment, say — then this example raises TypeError on the open() and then raises the same TypeError again out of Path(None) in the finally, masking the first. That is a rough first experience for copy-pasted code.
If elements_file is always set against a current server, the sentence is fine as written and this is moot.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
The contract was made uniform in general.py, so the sentence is now true as written — elements_file is always set when the header is passed.
I also corrected the caveat itself, which was wrong in a way you did not flag: it said "non-PDF, or split_pdf_page=False" and omitted small PDFs entirely. Because split_size is floored at 2 and the hook short-circuits on split_size >= page_count, one- and two-page PDFs are sent whole too. It now reads "two pages or fewer".
The double-TypeError is gone with the contract fix, and the example uses Path(...).unlink(missing_ok=True) so a failure to open the file is not masked by the cleanup.
Worth noting the same defect then turned up in my own tests — a later pass caught bare os.unlink in the finally blocks of the new end-to-end tests, masking assertion failures exactly as the README example would have. Fixed in ba3e7f4.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| assert "run: make install" in workflow | ||
|
|
||
|
|
||
| def test_partition_response_keeps_elements_file(): |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I think this test and the two new .genignore entries can come out — the regeneration they're defending against has been hard-blocked for months.
speakeasy_sdk_generation.yml has 100 consecutive failed runs, oldest in the queryable window 2026-04-26, newest today.##[error]generation access blocked, i.e. Speakeasy account level..speakeasy/gen.lock hasn't moved since 2026-01-18.So no regeneration can drop elements_file, because no regeneration completes. That's roughly 38 lines that can go: this test, the 11 new .genignore lines, and the Regeneration section of the description.
If any of it does stay, the comments need rewording. Both this test and the .genignore entries assert that "the daily generation workflow would drop the row on its next run," which is now just false and will mislead the next person to read it. The test also string-matches paths inside .genignore, which is a gate that costs maintenance and catches nothing.
To be clear this is a call about where the repo is heading rather than a defect in your work — the premise you were given was reasonable, it just doesn't hold anymore. Given generation is blocked at the account level, the burden is on restoring Speakeasy rather than on defending against it. Happy to own that decision if it's easier; I'll follow up separately on whether that cron should just be turned off.
One knock-on if you do drop it: the PartitionAcceptEnum.APPLICATION_X_NDJSON assertion in here is currently the only test reference to the new enum, so it'd be worth folding that into whatever end-to-end test covers the mode.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I verified all three of your data points before touching anything: 100 consecutive failed runs (oldest in the queryable window 2026-04-26, newest today), ##[error]generation access blocked, and .speakeasy/gen.lock unchanged since 2026-01-18. Your conclusion holds — nothing can drop the field because nothing completes.
I have left the structural decision to you rather than removing it unilaterally, since you offered to own it and it is a call about where the repo is heading.
What I did do is fix the wording, which was false regardless of that decision. The .genignore comment and this test's docstring no longer claim an imminent regeneration; they now state that generation is blocked at the account level and that the entries are insurance for when it is restored. I also posted a correction on the partitionresponse.md thread, where I had asserted the row would disappear "within a day of merging" — I had checked that the cron existed without checking that the workflow succeeds, which is on me.
One update relevant to your decision: the PartitionAcceptEnum.APPLICATION_X_NDJSON assertion here is no longer the only test reference to the enum. The new end-to-end tests in test_ndjson_elements_file.py use it in five places, so if you drop this test the enum keeps its coverage and nothing needs folding in first.
For what it is worth, my own read is to keep the two .genignore entries — they are three lines and correct whenever generation returns — and drop the string-matching of .genignore contents from this test, since that part asserts on a config file rather than on behavior. But I am happy either way and will action whatever you decide.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
The deployed API does not offer application/x-ndjson -- its spec (v1.5.69) declares only application/json and text/csv, and the string "ndjson" appears nowhere in the document. So any request that bypasses the split-PDF hook came back as JSON, the application/json branch matched first and won, and elements_file stayed None while elements was populated. The opt-in was silently ignored. That path is much wider than "non-PDF". _before_request_unlocked short-circuits when split_size >= page_count, and get_optimal_split_size floors at MIN_PAGES_PER_SPLIT=2, so one- and two-page PDFs are sent whole -- it would have read as intermittent to anyone hitting it on small inputs. The x-ndjson branch is now checked first, and a JSON body is written out as NDJSON when NDJSON was requested. elements_file is therefore set for every input and callers need one code path rather than two. This does not bound memory on the unsplit path -- the body is already read and parsing adds the element list on top -- it makes the contract uniform. The memory win remains the split-PDF path, where the hook concatenates chunk files on disk and this conversion is never reached. Also fixes the README example, which raised TypeError from open(None) and then raised the same TypeError again out of Path(None) in the finally, masking the first. Adds the end-to-end coverage whose absence let this through: all 24 existing tests were helper- or hook-level, so nothing exercised the media-type dispatch in general.py. Three tests now drive partition() over a mock transport -- server returns JSON, server returns NDJSON, and no override at all. The first fails against the old dispatch. They hold the client for the call rather than chaining off a temporary, since sdk.py registers a weakref.finalize that closes the transport. Smaller review notes: log a warning per skipped empty chunk so element_count can be reconciled against chunk_count; document that the non-"[" sniff assumes one JSON value per line rather than checking; trim the _combine_chunks_to_ndjson docstring. The regeneration comments no longer claim a daily workflow is about to drop the field -- generation is blocked at the Speakeasy account level and gen.lock has not moved since 2026-01. Whether that scaffolding is worth keeping is left open. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
All reported issues were addressed across 8 files (changes from recent commits).
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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Four review findings on the elements-file path, all reproduced first.
The JSON-to-NDJSON conversion iterated `http_res.json()` directly, bypassing the
response schema. A `null` body raised TypeError from iterating None, and a non-array
body was worse than that: iterating a dict yields its keys, so `{"detail": "oops"}`
was written out as a one-element document reading `"detail"`. It now goes through
`unmarshal_json_response`, so a malformed 200 fails exactly as it does on the
`elements` path, and `null` yields an empty file.
NDJSON detection keyed off `accept_header_override` alone, but `http_headers` can
replace Accept too -- and the split-PDF hook already keys off the request header.
So one caller passing `http_headers={"Accept": "application/x-ndjson"}` got
`elements_file` for a multi-page PDF and `elements` for a two-page one. Detection now
reads the header off the built request, which is the same source the hook uses.
The async path ran the parse-and-write inline, blocking the event loop for exactly
the large bodies this mode exists for. Offloaded with `asyncio.to_thread`.
Test `finally` blocks used a bare `os.unlink`, which masks an assertion failure above
it with FileNotFoundError -- the same defect flagged in the README two rounds ago,
reintroduced in the tests. Now `Path(...).unlink(missing_ok=True)`.
Also removes a duplicated "The" left in `_combine_chunks_to_ndjson`'s docstring by
the earlier trim.
Four tests added, each confirmed to fail against the pre-fix source. The async one
originally asserted `asyncio.to_thread` was called, which passed even with the
offload removed because other SDK internals use it during the same call; it now
records the thread the conversion ran on and compares it to the event-loop thread.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
All reported issues were addressed across 3 files (changes from recent commits).
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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
The API does not negotiate response format on Accept at all: it reads the output_format form field, and only consults Accept to select multipart/mixed and to reject conflicting media types on multi-file uploads. Saying the spec merely omits application/x-ndjson understated it -- NDJSON was never going to arrive via Accept regardless of what the spec declared. Comment only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Thanks — this was a genuinely useful review. All three inline asks are addressed and answered in their threads; the four smaller notes are done too:
Two things worth surfacing from working through the review, both in the threads but easy to miss:
Also fixed since your review, from an automated pass over the same code: the JSON conversion was iterating the raw body so a non-array 200 wrote a dict's keys out as elements; NDJSON detection ignored Current state: 248 unit + 64 contract tests pass, pylint 10.00/10, mypy clean. The only open item is your call on the regeneration scaffolding. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
0 issues found across 1 file (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.
Re-trigger cubic
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Offloading the JSON-to-NDJSON conversion to a thread last commit removed the event loop block but introduced a leak: a thread cannot be cancelled, so on cancellation the conversion still runs to completion and creates its file, while the awaiting coroutine has already raised CancelledError and discarded the path. Nothing was left that could delete it. Reproduced -- cancel mid-conversion and an unst_elements_*.ndjson survives. The conversion is now awaited through asyncio.shield, which keeps a handle on the thread's result after the caller stops waiting, and a done callback unlinks the finished file. A conversion that raised needs no callback, since it already removes its own partial file. This is the same shape as the split hook's cancellation race: work that outlives the operation that requested it, publishing a path nobody will read. It did not exist on the inline version, which had no await point to cancel at. Regression test asserts against the path production actually created, so it cannot pass by observing that no file was ever made. Confirmed to fail against the plain awaited to_thread. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
1 issue found across 2 files (changes from recent commits).
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:772">
P3: This cancellation test can race on slower or busy CI runs: the conversion may finish before `task.cancel()` because it relies on a fixed `time.sleep(0.3)`. Synchronizing the worker with `threading.Event` (signal started, then wait for release) would make the cancellation scenario deterministic instead of wall-clock dependent.</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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
The test raced. It relied on a fixed `time.sleep(0.3)` in the worker outlasting an `await asyncio.sleep(0.05)` before `task.cancel()`. On a loaded runner the conversion could finish first, leaving nothing to cancel, and the test would fail with "DID NOT RAISE" -- a spurious failure rather than a real one. Now sequenced with three `threading.Event` handshakes: the worker signals that it has entered the conversion, the test cancels only after that, and only then releases the worker, so completion is necessarily post-cancellation. A spy on `_discard_elements_file` signals that cleanup ran, so the assertion waits on the actual event rather than polling the filesystem on a timer. The `wait` timeouts are deadlock guards, never a duration anything waits out. Also drops the 0.02s x 200 polling loop, so the test now takes ~0.15s instead of ~0.5s, and reports "cleanup never ran" instead of a bare assertion when it fails. Still confirmed to fail against the plain awaited to_thread. Ran 20x clean, and 10x clean under eight busy cores. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
0 issues found across 1 file (changes from recent commits).
Shadow auto-approve: would require human review. Adds a new NDJSON mode and elements_file response field. Requires human sign-off on the public API expansion, the manual override of SDK generation via .genignore, and the design decision to delegate temp-file deletion to the caller.
Re-trigger cubic
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
LGTM! Final non blocking claude notes:
One non-blocking note on the async conversion, everything else from my earlier threads reads as settled.
The cancellation cleanup depends on the event loop outliving the worker thread. Shielding the task and deleting the file in a done-callback is the right shape given a thread can't be cancelled — the file gets created whether or not anyone still wants it, so something has to hold the path. The residual is that add_done_callback only fires if the loop is still processing callbacks when the thread finishes. I ran both orderings against json_body_to_elements_file_async with a slowed conversion: hold the loop open for a second after cancelling and the temp directory is empty; let asyncio.run tear down immediately after the same cancel and one unst_elements*.ndjson survives. The second ordering is the ordinary one for a cancelled request, and test_async_conversion_cleans_up_when_cancelled sequences the first, so the leak isn't covered.
One shape that would take the loop out of it: choose the output path in the coroutine rather than inside the thread, pass it down, and unlink it in the except BaseException you already have. On POSIX the thread's open descriptor keeps writing to the unlinked inode and the file disappears on close, so cleanup no longer needs anything to run later. That does behave differently on Windows, where unlinking an open file fails, so it's worth a second opinion if Windows is in scope for you.
Not worth holding the release for — one file per cancelled-and-exited async request, in a path that only runs when the caller has already given up.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.