diff --git a/demes/load_dump.py b/demes/load_dump.py index b6bf5813..4c170ddc 100644 --- a/demes/load_dump.py +++ b/demes/load_dump.py @@ -111,6 +111,31 @@ def _unstringify_infinities(data: MutableMapping[str, Any]) -> None: data["defaults"][default]["start_time"] = float(start_time) +def _no_null_values(data: MutableMapping[str, Any]) -> None: + """ + Checks for any null values in the input data. + """ + + def check_if_None(key, val): + if val is None: + raise ValueError(f"{key} must have a non-null value") + + def assert_no_nulls(d): + for k, v in d.items(): + if isinstance(v, dict): + assert_no_nulls(v) + elif isinstance(v, list): + for _ in v: + if isinstance(_, dict): + assert_no_nulls(_) + else: + check_if_None(k, v) + else: + check_if_None(k, v) + + assert_no_nulls(data) + + def loads_asdict(string, *, format="yaml") -> MutableMapping[str, Any]: """ Load a YAML or JSON string into a dictionary of nested objects. @@ -149,6 +174,9 @@ def load_asdict(filename, *, format="yaml") -> MutableMapping[str, Any]: data = _load_yaml_asdict(f) else: raise ValueError(f"unknown format: {format}") + # We forbid null values in the input data. + # See https://github.com/popsim-consortium/demes-spec/issues/76 + _no_null_values(data) # The string "Infinity" should only be present in JSON files. # But YAML is a superset of JSON, so we want the YAML loader to also # load JSON files without problem. diff --git a/tests/test_load_dump.py b/tests/test_load_dump.py index 601bd055..57211557 100644 --- a/tests/test_load_dump.py +++ b/tests/test_load_dump.py @@ -594,6 +594,111 @@ def test_json_infinities_get_stringified(self): g2 = demes.loads(json_str, format="json") g2.assert_close(g1) + def test_load_with_null_deme_start_time(self): + model = """ +time_units: generations +demes: +- name: a + start_time: null + epochs: + - {end_time: 0, start_size: 1} +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_deme_name(self): + model = """ +time_units: generations +demes: +- name: null + epochs: + - {end_time: 0, start_size: 1} +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_time_unit(self): + model = """ +time_units: null +demes: +- name: a + epochs: + - {end_time: 0, start_size: 1} +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_epochs(self): + model = """ +time_units: generations +demes: +- name: a + epochs: +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_epoch_end_time(self): + model = """ +time_units: generations +demes: +- name: a + epochs: + - {end_time: null, start_size: 1} +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_migrations(self): + model = """ +time_units: generations +demes: +- name: a + epochs: + - {start_size: 1} +- name: b + epochs: + - {start_size: 1} +migrations: +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + + def test_load_with_null_migration_demes(self): + model = """ +time_units: generations +demes: +- name: a + epochs: + - {start_size: 1} +- name: b + epochs: + - {start_size: 1} +migrations: +- demes: + rate: 0.01 +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + model = """ +time_units: generations +defaults: + migration: + demes: [a, b] +demes: +- name: a + epochs: + - {start_size: 1} +- name: b + epochs: + - {start_size: 1} +migrations: +- demes: + rate: 0.01 +""" + with pytest.raises(ValueError, match="must have a non-null value"): + demes.loads(model) + class TestMultiDocument: @pytest.mark.parametrize("yaml_file", tests.example_files())