Skip to content

fix: make the shapefile encoding retry actually decode latin-1 data - #113

Open
AlexianMasson wants to merge 4 commits into
mainfrom
fix/shapefile-encoding-detection-2
Open

fix: make the shapefile encoding retry actually decode latin-1 data#113
AlexianMasson wants to merge 4 commits into
mainfrom
fix/shapefile-encoding-detection-2

Conversation

@AlexianMasson

Copy link
Copy Markdown
Collaborator

Follow-up on #107

The encoding retry added there still failed on the case it was opened for: a zipped latin-1 shapefile with no .cpg sidecar. This branch fixes the three reasons it did.

What was broken

The .cpg sidecar was sniffed instead of parsed. A .cpg declares the .dbf encoding as ASCII text (ISO-8859-1, UTF-8, ANSI 1252, ...), so handing it to chardet always reported ascii — which decodes nothing, and the retry failed outright.

Detection could only ever return utf-8. With no usable .cpg, detection fell through to utf-8 and the retry simply re-ran the read that had just raised. No byte-level detector can recover the codepage here: chardet reads latin-1 .dbf records as cp1250 at 0.99 confidence, which would silently corrupt text rather than fail.

The string-materialization loop never did what it was for, and broke valid files. It was meant to force pyarrow's lazy text validation inside the try block, but with PYOGRIO_USE_ARROW gpd.read_file already raises while converting to pandas (loop unreachable), and without it GDAL hands back correctly decoded text (nothing to validate — .to_numpy() validates nothing either). Meanwhile, on frames with repeated field names — which GDAL does produce — result[label] is a DataFrame, which has no .dtype, so an AttributeError escaped the except clause and aborted the whole ingest for files that read fine before.

What changed

  • Parse the .cpg as an encoding name (_parse_cpg_encoding), taking the trailing token so ANSI 12521252 and LDID/8787 resolve via codecs.lookup(). A UTF-8/ASCII declaration is discarded: detection only runs after a UTF-8 read has failed, so honouring it would repeat that read.
  • Fall back to cp1252 for shapefiles that declare no usable encoding, with a WARNING naming the guess so mojibake is traceable and the fix (add a .cpg) is stated at the source.
  • Drop the .dbf samplingchardet cannot read a binary .dbf header, and on larger ones it confidently guesses the wrong codepage.
  • Remove the materialization loop; widening except (UnicodeDecodeError, ArrowException) is what actually triggers the retry, and the original error is now logged so a non-encoding ArrowException isn't reported as an encoding problem. ArrowException is imported from the public pyarrow namespace.
  • Name the encoding defaults (_DEFAULT_ENCODING, _UTF8_COMPATIBLE_ENCODINGS, _SHAPEFILE_FALLBACK_ENCODING) instead of repeating "utf-8" in five places. The constants must be normalized codec names to compare equal against codecs.lookup().name; a test pins that.

Tests

13 new tests in libs/data_manipulation/tests/test_ingestion.py covering: loose and zipped shapefiles honouring a .cpg, numeric codepage resolution, unusable/UTF-8 declarations being ignored, the cp1252 fallback (loose and zipped), GeoJSON always UTF-8, plain-text sniffing, zips without a shapefile, unreadable files, duplicate column labels, and constant normalization.

Files touched: libs/data_manipulation/src/data_manipulation/ingestion.py, libs/data_manipulation/tests/test_ingestion.py.

@AlexianMasson
AlexianMasson requested a review from f-necas August 18, 2026 08:19
@f-necas
f-necas force-pushed the fix/shapefile-encoding-detection-2 branch from 0b156a9 to ef9b58e Compare August 25, 2026 09:30
Base automatically changed from fix/shapefile-encoding-detection to main August 25, 2026 10:56
A .cpg *declares* the .dbf encoding as ASCII text ("ISO-8859-1",
"UTF-8", "ANSI 1252"...), so handing it to chardet always reported
"ascii" -- which decodes nothing and made the retry fail outright.

Parse it instead, and discard a UTF-8/ASCII declaration: detection only
runs after a UTF-8 read has already failed, so honouring such a
declaration would just repeat that read. GDAL already honours a valid
.cpg on the first read, so this branch is reached precisely when the
sidecar is missing, wrong or unrecognised.

Also drop the .dbf sampling: chardet cannot read a .dbf (binary header
-> "encoding: None"), and when it does answer on a larger one it guesses
a plausible-but-wrong codepage (cp1250 for latin-1 data at 0.99
confidence), silently corrupting the text instead of failing.
This is the case the branch was opened for. A zipped latin-1 shapefile
with no .cpg (or one that wrongly claims UTF-8) still failed: detection
returned "utf-8", so the retry re-ran the read that had just raised.

GDAL honours a valid .cpg and the .dbf language driver id by itself, so
a shapefile that reaches detection declares neither, and no byte-level
detector can pick its codepage -- chardet reads latin-1 records as
cp1250 at 0.99 confidence, which would silently corrupt the text. Assume
cp1252 instead and log a WARNING naming the guess, so mojibake is
traceable rather than silent.

Covered end to end with a real latin-1 shapefile, parametrized over
PYOGRIO_USE_ARROW: only the Arrow path raises on undecodable text, and
Airflow sets it (docker/compose.airflow.yaml) while local runs do not.
The loop was meant to force pyarrow's lazy text validation so a bad
encoding would raise inside the try block instead of much later in
pandas.to_sql. It never did:

- with PYOGRIO_USE_ARROW, gpd.read_file already raises the
  ArrowException while converting the arrow table to pandas, so the loop
  is unreachable;
- without it, pyogrio hands back correctly decoded text (GDAL recodes
  from the declared encoding, or from ISO-8859-1), so there is nothing
  to validate -- and .to_numpy() does not validate anything anyway: it
  returns object cells untouched, surrogate escapes included.

Meanwhile it broke frames with repeated field names, which GDAL does
produce: result[label] is a DataFrame there, and DataFrame has no
.dtype, so an AttributeError escaped the except clause and aborted the
whole ingest for files that read fine before.

The widened `except (UnicodeDecodeError, ArrowException)` is what
actually makes the encoding retry trigger; it now logs the original
error so a non-encoding ArrowException isn't reported as an encoding
problem. Also import ArrowException from the public pyarrow namespace.
_detect_file_encoding returned the bare string "utf-8" from four places
and compared against ("utf-8", "ascii") in a fifth. Introduce
_DEFAULT_ENCODING and _UTF8_COMPATIBLE_ENCODINGS next to the existing
_SHAPEFILE_FALLBACK_ENCODING, so the three encoding decisions this module
makes each have a name.

There is no standard-library constant to use instead: codecs exposes BOM
byte sequences but no codec name, sys.getdefaultencoding() names the
codec str.encode() defaults to rather than a file's encoding, and
locale.getpreferredencoding() is environment-dependent and returns a
differently-cased name.

_UTF8_COMPATIBLE_ENCODINGS is matched against codecs.lookup().name, so
its entries have to be in normalized form -- "utf8" is a real codec but
would never compare equal. A test pins that for all three constants.
@f-necas
f-necas force-pushed the fix/shapefile-encoding-detection-2 branch from ef9b58e to f6498f2 Compare August 25, 2026 10:56
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