fix(encoding): pin encoding=utf-8 on dialect.py + config.py text opens - #2098
Conversation
dialect.py: all 11 text-mode open() calls (6 read, 5 write) omitted encoding=, so on a non-UTF-8-locale process (e.g. German Windows / cp1252) UTF-8-written JSON and AAAK text is decoded via the OS codepage, corrupting umlauts. config.py: 4 text opens lacked encoding= (config.json + people_map read paths, two writes); the other json.dump write paths already pinned UTF-8. Audit findings MemPalace#51 (dialect.py:360) and MemPalace#84 (config.py:377). Regression: tests/test_encoding_hardening.py forces cp1252 default open and asserts umlaut round-trips through from_config / config.json read / raw-UTF-8 skip_name.
… coverage Third test now genuinely fails pre-fix (raw UTF-8 skip_name read), fixing the reviewer-flagged passes-either-way case. skip_names are lowercased on load, so assert on .lower().
arnoldwender
left a comment
There was a problem hiding this comment.
Pinning encoding="utf-8" is the right call, and the cp1252_default_open fixture is a genuinely nice way to make the Windows failure reproducible on any host — that fixture is reusable well beyond this PR.
One gap worth considering before this lands, because it is the mirror image of what the tests cover.
The new tests all exercise UTF-8 bytes on disk, read under a cp1252 default locale — the mojibake direction. They do not cover legacy-codepage bytes already on disk, read under the now-forced UTF-8. That second direction is the migration case, and it changes a degraded read into a hard failure:
>>> issubclass(UnicodeDecodeError, (json.JSONDecodeError, OSError))
False # UnicodeDecodeError -> UnicodeError -> ValueErrorSo the existing guards in config.py — except (json.JSONDecodeError, OSError) on both the _config_file read and the people_map read — do not catch it. Reproduced against this branch:
--- before (locale cp1252 read) ---
OK -> reads without crashing. keys: [Müller, Ökonomie]
--- after (encoding="utf-8" forced) ---
CRASH: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xfc in position 24: invalid start byte
caught by (JSONDecodeError, OSError)? False
Scope is genuinely bounded, and I want to be fair about that: every file MemPalace writes itself goes through json.dump(...) with the default ensure_ascii=True, so it is pure ASCII and decodes under any codec. This can only bite a hand-edited file.
Which is exactly why Dialect.from_config is the sharp edge here. It has no try/except at all, so the traceback reaches the user raw — and dialect.py explicitly instructs them to hand-edit that very file:
print("Edit this file with your own entity mappings, then use --config entities.json")
A German, Russian or Chinese user following that instruction is going to put non-ASCII into an entity map (that is the whole point of an entity map for them), and if their editor saves cp1252/GBK rather than UTF-8, this PR turns a working-if-imperfect read into an unhandled crash. That is the same population the PR is written to protect.
Cheap fix, in the spirit of what is already here:
except (json.JSONDecodeError, UnicodeDecodeError, OSError):for the two config.py guards, and for from_config a guard that names the cause rather than replacing bytes silently — something like re-raising as ValueError(f"{config_path} is not valid UTF-8 — re-save it as UTF-8"). Silent errors="replace" would be worse than the status quo here, since it would corrupt entity names rather than report the problem.
Happy to send this as a follow-up PR on top of yours if you would rather keep this one tightly scoped to the read/write pinning — your call, and either way this is a clear improvement over what is on develop today.
Separately: #1600 (fix: add explicit UTF-8 encoding to all text file I/O on Windows) is still open and overlaps this file set, so whichever lands second will likely need a rebase.
|
@arnoldwender thanks — reproduced the legacy-codepage migration case exactly and folded the bounded fix into this PR. What changed:
Verification on the review branch:
Latest head is |
|
@KeilerHirsch that's exactly the fix I was hoping for, and the fallback matching the existing invalid-config path (rather than inventing a new one) is the right call — thanks for turning it around so fast. I re-ran your verification locally instead of taking the numbers on trust, because a claim like "3/3 RED before" is only meaningful if the falsification point is the right commit. Your branch gives a clean one: So the three migration tests are genuinely load-bearing, not passes-either-way. Confirmed too: no Two notes, neither of them a request for changes. 1. The actionable message lands, but wrapped in a traceback. I want to be precise about whose problem that is: it is not a regression from this PR. Pre-fix the same command raised a raw 2. One thing I checked and am clearing, so nobody re-raises it. The new message contains U+2014, and Nothing blocking from me. This looks ready to land as far as I'm concerned, and the |
igorls
left a comment
There was a problem hiding this comment.
Wave 1 for 3.7.0: LGTM. Focused fix with tests (or trivial deploy/manifest change). Merging into develop for the release train.
What
Pin
encoding="utf-8"on the text-modeopen()calls indialect.pyandconfig.pythat omitted it. Without an explicit encoding, Python decodes/encodes using the OS locale codepage — on a non-UTF-8 locale (e.g. German Windows = cp1252) this corrupts non-ASCII content in files MemPalace itself writes as UTF-8.The defect
config.pyalready writesconfig.json/people_map.jsonas UTF-8 (json.dump(..., ensure_ascii=False)withencoding="utf-8"), but the read paths (config.py:377config load,config.py:553people_map) opened without an encoding. That asymmetry means aMüllerwritten as UTF-8 is read back as cp1252 →Müller.dialect.pyhad the same gap across all 11 of its text opens.Concretely, on a cp1252 process:
Changes
mempalace/dialect.py—encoding="utf-8"on all 11 text-modeopen()calls (6 read, 5 write).mempalace/config.py—encoding="utf-8"on the 4 text opens that lacked it (the two read paths plus two writes). The four write paths that already pinned UTF-8 are untouched.tests/test_encoding_hardening.py— new regression tests that force encoding-less text opens to default to cp1252 (simulating a German Windows process), write real UTF-8 bytes to disk, and assert the umlaut round-trips throughDialect.from_config, theconfig.jsonread, and a raw-UTF-8skip_namesvalue.Verification
assert 'Müller' == 'M�ller') and GREEN after — verified by reverting only the two source files and re-running.test_dialect.py+test_config*.pytests still pass (no regression).ruff checkandruff format --checkboth clean on all three files.dialect.py'ssave_configusesjson.dump's defaultensure_ascii=True, so its umlauts are ASCII-escaped and codec-agnostic on write — the bug is the read side (and two non-JSON.write()text dumps). This PR does not claimsave_config's JSON write was corrupting data.Out of scope (same defect class, tracked separately)
miner.py(open(config_path) … yaml.safe_load) androom_detector_local.pyhave the identical encoding-less-open pattern for YAML config files. Not touched here to keep this PR to the two files the finding named; worth a follow-up.