fix: honor encoding= in partition_json and partition_ndjson - #4483
linhongyu510 wants to merge 2 commits into
Conversation
cragwolfe
left a comment
There was a problem hiding this comment.
Requesting changes for four production blockers:
-
[P1] Preserve valid UTF-8 when no encoding is supplied (
unstructured/partition/json.py:87,90,unstructured/partition/ndjson.py:84,87). The old paths decoded bytes strictly as UTF-8. The new paths callread_txt_file(..., encoding=None), which accepts a sufficiently confidentcharset_normalizer.detect()guess before attempting UTF-8. With the lockedcharset-normalizer==3.4.7, valid UTF-8 JSON containing a literal zero-width space can be selected as CP932 and still parse successfully, changing the element text (for example,RAGbecomesR窶帰G). This is silent content corruption. Please make explicit encodings authoritative and, when encoding is omitted, preserve valid UTF-8 before falling back to non-UTF-8 detection. Add assertions on exact Unicode content for JSON and NDJSON through both filenames and binary streams; the current ASCII assertions do not cover this regression. -
[P1] Preserve the existing file-like contract (
unstructured/partition/json.py:90-91,unstructured/partition/ndjson.py:87-88). The removed implementation accepted any readable object and preserved already-decodedstrvalues. The new no-encoding path routes the live object throughconvert_to_bytes(), which only accepts a few concrete types, may reopen a stream by.name, and usesBytesIO.getvalue()rather than reading from the current cursor. Exact-head CI already demonstrates the regression: bothtest_unit (3.13)andtest_dockerfilefail intest_formskeysvalues_reads_savesbecausepartition_json(file=io.StringIO(...))now raisesValueError: Invalid file-like object type. Read the supplied stream once from its current position, passstrthrough, and decode buffered bytes without reopening or taking ownership of the caller's stream. Cover text streams, generic seekable binary streams, temporary-file wrappers, and nonzero cursor positions for both formats. -
[P1] Forward
encodingthrough the public dispatcher (unstructured/partition/auto.py:266-269).partition()bindsencodingas a named parameter and uses it for detection, but its JSON/NDJSON special case calls the selected partitioner with onlyfilename,file, and**kwargs. Because the bound value is not inkwargs,partition(..., encoding=...)still discards the caller's requested codec before parsing, leaving the stated top-level behavior unfixed and potentially producing different text or error behavior from a directpartition_json()/partition_ndjson()call. Passencoding=encodingexplicitly and add dispatcher-level tests for both formats using bytes that have different meanings or validity under competing codecs; a BOM-bearing UTF-16 success alone can pass through autodetection while the argument is still dropped. -
[P2] Keep the existing fourth positional argument compatible (
unstructured/partition/json.py:36-42,unstructured/partition/ndjson.py:36-42). Before this change,metadata_last_modifiedwas the fourth positional parameter. Insertingencodingahead of it silently rebinds existing calls: binary input attempts to use the timestamp as a codec and raisesLookupError, whiletext=input can succeed but lose the requested timestamp. Preserve the original parameter order and place the new parameter afterward (or otherwise make only the new parameter keyword-only), with regression coverage for four-positional-argument calls in both partitioners.
The branch also currently conflicts with main in CHANGELOG.md; that is operational follow-up after the content blockers above are addressed.
(authored by codex)
Both partitioners read the source with a hard-coded UTF-8:
with open(filename, encoding="utf8") as f:
file_text = f.read()
and, for a file-like object, a bare .decode() (also UTF-8). Neither
accepted an `encoding` parameter, so an `encoding=` passed by the caller
-- directly or via partition() -- landed in **kwargs and was discarded
without a warning. A UTF-16 document raised UnicodeDecodeError even when
its encoding was stated explicitly:
partition_json(filename="utf16.json", encoding="utf-16")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0
partition_text() and partition_csv() both take `encoding` and read
through read_txt_file(), which uses the given encoding when present and
detects it otherwise. Route the two JSON partitioners through the same
helper and give them the same parameter.
Reading a non-UTF-8 document without stating its encoding now works too,
since read_txt_file() falls back to detection.
…rward encoding Four fixes from review of Unstructured-IO#4483: 1. Valid UTF-8 is preserved when no encoding is supplied. Routing through `read_txt_file(..., encoding=None)` made charset detection authoritative, and `charset_normalizer` can report high confidence in a legacy codec for bytes that are also valid UTF-8 -- a run of zero-width spaces is detected as CP932 at 0.907 and still parses as JSON, so the text was silently rewritten. Decoding now tries UTF-8 first and falls back to detection only for bytes that are not valid UTF-8. An explicit `encoding` stays authoritative and is used as given. 2. The file-like contract is restored. `convert_to_bytes()` accepts only a few concrete types, may reopen a stream by `.name`, and reads via `BytesIO.getvalue()` rather than from the cursor, which broke `partition_json(file=io.StringIO(...))`. The stream is again read once from its current position, an already-decoded `str` passes through, and the object is never reopened. 3. `partition()` forwards `encoding` to the JSON/NDJSON partitioners. It is bound as a named parameter, so it never reached them through `**kwargs` and the dispatcher silently dropped the caller's codec. 4. `metadata_last_modified` is the fourth positional parameter again; `encoding` follows it, so existing four-positional-argument calls keep binding as before. Both partitioners now share one `read_json_text()` helper so the two paths cannot drift apart.
21ca66c to
fb8f5bb
Compare
|
Thanks — all four are addressed, and the branch is rebased onto current I reproduced each one before changing anything, against 1. Valid UTF-8 is preserved when no encoding is supplied. Confirmed, and worth recording how it reproduces: my first attempt did not show corruption because Decoding now tries UTF-8 first and only falls back to detection for bytes that are not valid UTF-8. An explicit 2. The file-like contract is restored. Reproduced exactly as CI showed — 3. 4. Positional order is preserved. Both partitioners now share one Verification
One note on test placement: the dispatcher tests live in |
|
@cragwolfe Requesting a re-review when you have a moment — all four P1s from your 09-15 review were addressed in the 09-16 push (head Not re-litigating the detail here since it is in my 09-16 reply, just the state: CI on this head is green across the board, and there are no unresolved review threads left. The only thing holding If anything in the responses did not land the way you intended, I would rather hear that than have it sit — happy to keep iterating. |
Problem
partition_json()andpartition_ndjson()read the source with a hard-codedUTF-8, and neither accepts an
encodingparameter:Because both signatures end in
**kwargs: Any, anencoding=passed by thecaller — directly, or forwarded by
partition()— is swallowed silently. Onmain(ee2b3a35):The same call succeeds for the sibling text formats, which is where the
inconsistency shows:
encodingpartition_textread_txt_file()partition_csvpartition_jsonopen(..., encoding="utf8")partition_ndjsonopen(..., encoding="utf8")UTF-16 JSON is not exotic — it is what several Windows tools emit by default,
and
json.dumpto a file opened in a non-UTF-8 encoding produces it too.Fix
Add
encodingto both signatures and read through the existingread_txt_file()helper, exactly aspartition_text()does:read_txt_file()uses the given encoding when one is supplied and falls back todetect_file_encoding()when it is not, so this also makes a non-UTF-8 documentreadable without naming its encoding. The
text=path is untouched, andfile.seek(0)is preserved so the detect-then-partition sequence over one handlestill works.
Source change is +11/-8 across the two files; no new helper, no behaviour change
for UTF-8 input.
Verification
The 35 failures are pre-existing on
mainin this environment — they come fromunstructured/nlp/tokenize.pyfailing to import an optional dependency(
ModuleNotFoundError: No module named 'installer'), unrelated to these files.I compared the failure sets with
git stash: identical before and after, andthe pass count goes 70 → 75, which is exactly the five tests added here.
Five regression tests, following the existing naming style in both files:
encoding="utf-16"forfilename=(both partitioners)encoding="utf-16"forfile=(both partitioners)encodinggiven, UTF-16 detected (json)Rollback proof. Restoring just the two read blocks to the hard-coded form —
leaving the new parameter in place — fails exactly those five and nothing else:
each with
UnicodeDecodeError.ruff checkon the four changed files is clean,and a CHANGELOG entry is added under the current
0.27.8-dev0section.Notes
This is the parsing side. There are open PRs (#4397, #4398, #4447) addressing
non-UTF-8 crashes during file-type detection in
file_utils/filetype.py; Ichecked all currently open PRs and none touches
partition/json.pyorpartition/ndjson.py, so this does not overlap with them.I kept the scope to honoring the parameter. I did not change what happens when a
document really is undecodable, nor touch the
text=path.