Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions tests/test_nan_robustness.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
"""
import json
import math
import sys
from unittest.mock import patch

import numpy as np
import pandas as pd
import pytest

Expand Down Expand Up @@ -221,14 +223,47 @@ def test_json_writer_round_trips_real_payload():
assert parsed == payload


# ---- stdlib json baseline (for documentation; not a real test) ---------
@pytest.mark.parametrize("value", [np.float64('nan'), np.float64('inf'),
np.float64('-inf')])
def test_json_writer_handles_nested_numpy_floats_without_mutating_input(value):
from vaxrank.cli.entry_point import serialize_json_nan_tolerant

payload = {'nested': [{'value': value}]}
assert json.loads(serialize_json_nan_tolerant(payload)) == {
'nested': [{'value': None}]}
assert payload['nested'][0]['value'] is value


def test_json_writer_normalizes_serializable_containers_and_objects():
from vaxrank.cli.entry_point import serialize_json_nan_tolerant

class Payload:
def __init__(self, value):
self.value = value

payload = {'tuple': (float('nan'),), 'set': {float('inf')},
'object': Payload(float('-inf'))}
parsed = json.loads(serialize_json_nan_tolerant(payload))
assert parsed['tuple']['__value__'] == [None]
assert parsed['set']['__value__'] == [None]
assert parsed['object']['value'] is None
assert parsed['object']['__class__']['__name__'] == 'Payload'


def test_json_writer_preserves_report_encoding():
from vaxrank.cli.entry_point import serialize_json_nan_tolerant

payload = {'values': [float('nan'), float('inf'), float('-inf')],
'negative_zero': -0.0, 'tiny': 5e-324, 'large': 2 ** 64,
'text': 'café'}
assert serialize_json_nan_tolerant(payload) == (
'{"values": [null, null, null], "negative_zero": -0.0, '
'"tiny": 5e-324, "large": 18446744073709551616, "text": "caf\\u00e9"}')


def test_json_writer_works_when_simplejson_cannot_be_imported(monkeypatch):
from vaxrank.cli.entry_point import serialize_json_nan_tolerant

def test_simplejson_default_rejects_nan_as_documented():
"""Sanity: confirms the bug premise. The stdlib + simplejson
*default* path rejects NaN — which is why we wrapped with
``ignore_nan=True``. This test fails the day simplejson changes
its default; if it ever does, we can remove the wrapper."""
import simplejson
with pytest.raises(ValueError, match='Out of range'):
simplejson.dumps({'x': float('nan')})
monkeypatch.setitem(sys.modules, 'simplejson', None)
assert json.loads(serialize_json_nan_tolerant({'value': float('nan')})) == {
'value': None}
30 changes: 20 additions & 10 deletions vaxrank/cli/entry_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logging
import json
import logging.config
import math
import os
import sys
from importlib.resources import files
Expand Down Expand Up @@ -76,15 +77,25 @@
logger = logging.getLogger(__name__)


def _nan_to_none(value):
"""Replace non-finite floats in normalized dict/list values with None."""
if isinstance(value, float) and not math.isfinite(value):
return None
if isinstance(value, dict):
return {key: _nan_to_none(item) for key, item in value.items()}
if isinstance(value, list):
return [_nan_to_none(item) for item in value]
return value


def serialize_json_nan_tolerant(obj):
"""Serialize ``obj`` to a JSON string, rendering NaN/Inf as
``null`` rather than raising. Defense-in-depth wrapper around
``serializable.to_json``; producers should already be coercing
NaN to None, but a writer-side safety net prevents the whole
report from being lost on a single rogue float (#289)."""
import simplejson
"""Serialize ``obj`` to strict JSON, rendering NaN/Inf as ``null``.

Producers should already coerce non-finite floats to None. This writer
also normalizes them so one bad float cannot lose the report (#289).
"""
from serializable.helpers import to_serializable_repr
return simplejson.dumps(to_serializable_repr(obj), ignore_nan=True)
return json.dumps(_nan_to_none(to_serializable_repr(obj)), allow_nan=False)


def filter_unannotatable_variants(variants):
Expand Down Expand Up @@ -1752,14 +1763,13 @@ def ranked_vaccine_peptides_with_metadata_from_parsed_args(args):
if args.output_json_file:
ensure_parent_dir(args.output_json_file)
with open(args.output_json_file, 'w') as f:
# ``ignore_nan=True`` is defense in depth — producers should
# Writer-side normalization is defense in depth — producers should
# be coercing NaN/Inf to None before reaching the writer
# (see vaxrank.finite_prediction_value), but if any
# slips through (e.g. a future predictor adapter, or a
# custom score expression), render it as JSON null instead
# of crashing the whole report. Note: strict JSON doesn't
# have a NaN literal — simplejson's default behavior is to
# raise; we explicitly opt into the null representation.
# have a NaN literal; the writer converts non-finite floats to null.
f.write(serialize_json_nan_tolerant(data))
logger.info('Wrote JSON report data to %s', args.output_json_file)

Expand Down
2 changes: 1 addition & 1 deletion vaxrank/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "3.38.1"
__version__ = "3.38.2"


def print_version():
Expand Down
Loading