diff --git a/tests/test_nan_robustness.py b/tests/test_nan_robustness.py index 84b34a47..c9c9e6f7 100644 --- a/tests/test_nan_robustness.py +++ b/tests/test_nan_robustness.py @@ -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 @@ -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} diff --git a/vaxrank/cli/entry_point.py b/vaxrank/cli/entry_point.py index 5e6f6222..f1e3d6a3 100644 --- a/vaxrank/cli/entry_point.py +++ b/vaxrank/cli/entry_point.py @@ -15,6 +15,7 @@ import logging import json import logging.config +import math import os import sys from importlib.resources import files @@ -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): @@ -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) diff --git a/vaxrank/version.py b/vaxrank/version.py index ca04ffa2..b5032d23 100644 --- a/vaxrank/version.py +++ b/vaxrank/version.py @@ -1,4 +1,4 @@ -__version__ = "3.38.1" +__version__ = "3.38.2" def print_version():