Skip to content

Warn (or raise in strict mode) when a number is too wide for its dbf field - #442

Closed
gaoflow wants to merge 1 commit into
GeospatialPython:masterfrom
gaoflow:fix-silent-numeric-field-truncation
Closed

Warn (or raise in strict mode) when a number is too wide for its dbf field#442
gaoflow wants to merge 1 commit into
GeospatialPython:masterfrom
gaoflow:fix-silent-numeric-field-truncation

Conversation

@gaoflow

@gaoflow gaoflow commented Aug 5, 2026

Copy link
Copy Markdown

Writer._record fits numbers into "N"/"F" fields with format(...)[:size].rjust(size), padding back to exactly size so the trailing len(encoded) != size guard can never fire. A truncated decimal representation is a different number, so this silently writes wrong data — with no warning even in strict mode, while the "C"/"M" path a few lines below raises DbfStringDataLoss:

w = shapefile.DbfWriter(dbf=stream, strict=True)
w.field("ID", "N", size=5)
w.record(123456789)      # stores b"12345"

Also N(size=2) + -999b"-9", N(size=1) + -5b"-" (reads back as None), and N(size=6, decimal=2) + 12345.67b"12345.", a bare decimal point in a field whose decimal count says 2.

Both numeric sub-paths (integer and fixed point) now go through one _pack_dbf_number, which warns, or raises the new DbfNumericDataLoss in strict mode, like the text path does. The bytes written are unchanged, so callers who are happy with the truncation just get a warning. DbfStringDataLoss and DbfNumericDataLoss share a new DbfDataLoss base (#146), so except DbfStringDataLoss is unaffected.

I checked the rest of the record writer for the same class before fixing it: "D" and "L" can't truncate because Field.from_unchecked forces their size to 8 and 1, and "C"/"M" already signal, so it really is just those two lines. Tests cover both sub-paths, signed and unsigned, integer and decimal, the fields that fit (no warning), and the D/L immunity.

tests/hypothesis_tests.py needed except shp.DbfStringDataLoss widened to DbfDataLoss: the strategy generates N(size=9, decimal=1) with 9999999.96875, which rounds to "10000000.0" and was being stored as b"10000000.". The round trip passed because the expected value is rounded the same way, so it matched the corrupted read-back.

One thing I left alone as it's a design call rather than a bug fix: float("inf") and float("nan") fit an 8 wide field and get written as b" inf" / b" nan", which aren't dbf numeric literals. Happy to follow up if you'd want NaN treated as MISSING.

Changelog is under 3.1.7 — move it if you'd rather have it elsewhere. Ran ruff check, ruff format, mypy --strict, pytest (170 passed), -m hypothesis (18), -m network (1 passed, 1 skipped), and tests/run_doctests.py (231).

… number is too wide for its dbf field

_record capped over-wide N and F values with format(...)[:size].rjust(size),
which pads back to exactly size, so the len(encoded) != size guard could never
fire.  Truncating a decimal representation writes a different number, e.g. an
N field of size 5 stored 123456789 as b"12345", and N(6, decimal=2) stored
12345.67 as b"12345.", with no warning even in strict mode.

Both numeric sub-paths now share _pack_dbf_number, which warns (or raises the
new DbfNumericDataLoss in strict mode) as the C and M paths do, without
changing the bytes written.  DbfStringDataLoss and DbfNumericDataLoss share a
new DbfDataLoss base class.
@JamesParrott

JamesParrott commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator
  • AI slop

  • Workflow actions not run.

  • PR makes API design decisions, and a version bump without first talking to the team

  • Author has recently been so prolific (implausibly so) that even if they did write all their contributions by hand, they can't possibly have given them enough consideration.

image
  • Unplanned.

  • No issue raised. Even if there was an issue, the title is accurate. However this PR just adds a warning for standard floating point approximation issues, that equally apply to any numerical software.

Let me know if you require otherwise, but I strongly suggest simply using a wide enough field for the particular data source. Or use a modern, non-obsolete data format that stores floats as floats, instead of as ascii (like .dbfs do).

  • The change to the code is very small, almost trivial. Which is great, actually, but it's on a fairly hot loop that I've deliberately not called out to sub functions from for speed purposes (or at least, not to make the speed hit too bad). But there are far too many tests and docstrings on a private function, when all the PR does is raise a warning or error, for something PyShp users care capable of fixing for themselves.

  • I fully agree silent truncation isn't great. But there is no onus on PyShp to support users who want to store floats or large ints in fields with insufficient precision. If a user really wants to do that, they will probably want to (and should) figure out which approximation best fits their use case too.

If fixing this is a biggie, or even just a nice-to-have for anyone, start a discussion and lets talk, human to human, and figure out what to do together, instead of just asking an LLM.

@gaoflow

gaoflow commented Aug 6, 2026

Copy link
Copy Markdown
Author

Fair callout, and honest answer since you asked directly: yes, I use an AI assistant for this — the digging and the patch — under my direction, and I'm accountable for what gets posted. Should have opened a discussion first instead of a PR; sorry for the noise.

On the substance: you're right that the tests/docstrings are too heavy for a one-line warning on a private hot-loop function, and if you'd rather not support truncation warnings at all that's a legitimate call — it's your API surface. Happy to trim it to the essentials if there's still interest, otherwise feel free to close.

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.

2 participants