diff --git a/docs/change_log.rst b/docs/change_log.rst index f44b29e..2080abf 100644 --- a/docs/change_log.rst +++ b/docs/change_log.rst @@ -22,6 +22,9 @@ Unreleased ^^^^^^^^^^ TODO: highlights +* `@pp-mo`_ data constructors support `attributes={name:value, ..}`. + (`PR#71 `_). + * `@pp-mo`_ dataset comparison routines now a public utility. (`PR#70 `_). diff --git a/lib/ncdata/_core.py b/lib/ncdata/_core.py index 3e818f3..9f31277 100644 --- a/lib/ncdata/_core.py +++ b/lib/ncdata/_core.py @@ -147,7 +147,10 @@ def from_items( If 'arg' is an iterable, its contents are added. - If 'arg' is a mapping, it must have (key == arg[key].name) for all keys. + If 'arg' is a mapping, it normally must have (key == arg[key].name) + for all keys. As a special case, only if `item_type` == + :class:`NcAttribute`, a plain name: value map can be provided, which + is converted to name: NcAttribute(name, value). If 'arg'' is a NameMap of the same 'item_type' (including None), then 'arg' is returned unchanged as the result. @@ -169,6 +172,20 @@ def from_items( if arg is not None: # We expect either another type of dictionary, or a list of items. if isinstance(arg, Mapping): + if ( + item_type == NcAttribute + and len(arg) > 0 + and not isinstance( + next(iter(arg.values())), NcAttribute + ) + ): + # for attributes only, also allow simple name=value map + # for which, convert each value to an NcAttribute + arg = { + name: NcAttribute(name, value) + for name, value in arg.items() + } + # existing mapping of NameMap type # ignore mapping keys, and set [name]=item.name for each value. result.addall(arg.values()) elif isinstance(arg, Iterable): diff --git a/tests/integration/example_scripts/ex_dataset_print.py b/tests/integration/example_scripts/ex_dataset_print.py index e63bbe0..5f3251f 100644 --- a/tests/integration/example_scripts/ex_dataset_print.py +++ b/tests/integration/example_scripts/ex_dataset_print.py @@ -2,7 +2,7 @@ import iris import ncdata.iris as nci -from ncdata import NcAttribute, NcData, NcDimension, NcVariable +from ncdata import NcData, NcDimension, NcVariable from tests import testdata_dir @@ -22,14 +22,14 @@ def sample_printout(): # noqa: D103 NcVariable( name="x", dimensions=["y", "extra_qq"], - attributes=[ - NcAttribute("q1", 1), - NcAttribute("q_multi", [1.1, 2.2]), - NcAttribute("q_multstr", ["one", "two"]), - ], + attributes={ + "q1": 1, + "q_multi": [1.1, 2.2], + "q_multstr": ["one", "two"], + }, ), ], - attributes=[NcAttribute("extra__global", "=value")], + attributes={"extra__global": "=value"}, ) print(ds) diff --git a/tests/integration/example_scripts/ex_ncdata_netcdf_conversion.py b/tests/integration/example_scripts/ex_ncdata_netcdf_conversion.py index 4468829..1890452 100644 --- a/tests/integration/example_scripts/ex_ncdata_netcdf_conversion.py +++ b/tests/integration/example_scripts/ex_ncdata_netcdf_conversion.py @@ -53,7 +53,7 @@ def example_nc4_save_reload_unlimited_roundtrip(): dtype=np.float32, data=np.arange(4), # Just an an attribute for the sake of it. - attributes=[NcAttribute("varattr1", 1)], + attributes={"varattr1": 1}, ) ncdata.attributes["globalattr1"] = NcAttribute("globalattr1", "one") print("Source ncdata object:") diff --git a/tests/unit/core/test_NameMap.py b/tests/unit/core/test_NameMap.py index acf5809..72650ce 100644 --- a/tests/unit/core/test_NameMap.py +++ b/tests/unit/core/test_NameMap.py @@ -5,7 +5,7 @@ import pytest -from ncdata import NameMap +from ncdata import NameMap, NcAttribute class NamedItem: @@ -144,6 +144,91 @@ def test_namemap_arg__bad_typeconvert__fail(self): NameMap.from_items(arg, item_type=OtherNamedItem) +class TestAttributesFromItems: + """ + Extra constructor checks *specifically* for attributes. + + Since they are treated differently in order to support the + "attributes={'x':1, 'y':2}" constructor style. + """ + + @pytest.fixture( + params=[None, NcAttribute, NamedItem], + ids=["none", "attrs", "nonattrs"], + ) + def target_itemtype(self, request): + return request.param + + @pytest.fixture(params=[False, True], ids=["single", "multiple"]) + def multiple(self, request): + return request.param + + def test_attributes__map(self, target_itemtype, multiple): + # Create from classic map {name: NcAttr(name, value)} + arg = {"x": NcAttribute("x", 1)} + if multiple: + arg["y"] = NcAttribute("y", 2) + + if target_itemtype == NamedItem: + msg = "Item expected to be of type.*NamedItem.* got NcAttribute" + with pytest.raises(TypeError, match=msg): + NameMap.from_items(arg, item_type=target_itemtype) + else: + namemap = NameMap.from_items(arg, item_type=target_itemtype) + assert namemap.item_type == target_itemtype + # Note: this asserts that the contents are the *original* uncopied + # NcAttribute objects, since we don't support == on NcAttributes + assert namemap == arg + + def test_attributes__list(self, target_itemtype, multiple): + # Create from a list [*NcAttr(name, value)] + arg = [NcAttribute("x", 1), NcAttribute("y", 2)] + if not multiple: + arg = arg[:1] + + if target_itemtype == NamedItem: + msg = "Item expected to be of type.*NamedItem.* got NcAttribute" + with pytest.raises(TypeError, match=msg): + NameMap.from_items(arg, item_type=target_itemtype) + else: + namemap = NameMap.from_items(arg, item_type=target_itemtype) + assert namemap.item_type == target_itemtype + assert list(namemap.keys()) == [attr.name for attr in arg] + # Again, content is the original objects + assert list(namemap.values()) == arg + + def test_attributes__namevaluemap(self, target_itemtype, multiple): + # Create from a newstyle map {name: value} + arg = {"x": 1} + if multiple: + arg["y"] = 2 + if target_itemtype != NcAttribute: + if target_itemtype is None: + msg = "Item has no '.name' property" + else: + # target_itemtype == NamedItem + msg = "Item expected to be of type.*NamedItem" + with pytest.raises(TypeError, match=msg): + NameMap.from_items(arg, item_type=target_itemtype) + else: + namemap = NameMap.from_items(arg, item_type=target_itemtype) + assert namemap.item_type == target_itemtype + assert list(namemap.keys()) == list(arg.keys()) + # Note: a bit of a fuss because we don't have == for NcAttributes + vals = list(namemap.values()) + assert all(isinstance(el, NcAttribute) for el in vals) + vals = [val.value for val in vals] + assert vals == list(arg.values()) + + @pytest.mark.parametrize( + "arg", [[], {}, None], ids=["list", "map", "none"] + ) + def test_attributes_empty(self, arg): + # Just check correct construction from empty args. + namemap = NameMap.from_items(arg, item_type=NcAttribute) + assert namemap == {} and namemap is not arg + + class Test_copy: def test_copy(self, item_type): source = sample_namemap(item_type=item_type) diff --git a/tests/unit/core/test_NcVariable.py b/tests/unit/core/test_NcVariable.py index b106369..dbc3e7c 100644 --- a/tests/unit/core/test_NcVariable.py +++ b/tests/unit/core/test_NcVariable.py @@ -172,9 +172,7 @@ def test_dimsnoargs(self): assert result == expected def test_oneattr(self): - var = NcVariable( - "var_w_attrs", attributes={"a1": NcAttribute("a1", 1)} - ) + var = NcVariable("var_w_attrs", attributes={"a1": 1}) result = str(var) expected = "\n".join( [ @@ -188,10 +186,7 @@ def test_oneattr(self): def test_multiattrs(self): var = NcVariable( "var_multi", - attributes={ - "a1": NcAttribute("a1", 1), - "a2": NcAttribute("a2", ["one", "three"]), - }, + attributes={"a1": 1, "a2": ["one", "three"]}, ) result = str(var) expected = "\n".join( @@ -218,7 +213,7 @@ def test_repr(self): var = NcVariable( "var", dimensions=("x", "y"), - attributes=[NcAttribute("a1", 1)], + attributes={"a1": 1}, ) result = repr(var) expected = f"" diff --git a/tests/unit/utils/test_save_errors.py b/tests/unit/utils/test_save_errors.py index 044c3e4..c903369 100644 --- a/tests/unit/utils/test_save_errors.py +++ b/tests/unit/utils/test_save_errors.py @@ -27,11 +27,11 @@ def _basic_testdata(): name="vx1", dimensions=("x"), data=[1, 2, 3], - attributes=[NcAttribute("xx", 1)], + attributes={"xx": 1}, ) ], groups=[NcData("inner")], - attributes=[NcAttribute("x", 1)], + attributes={"x": 1}, ) return ncdata @@ -122,7 +122,7 @@ def test_valid_datatypes(self, datatype, structuretype): # These produce "unsaveable datatype" errors. pytest.skip("invalid dtype fails") value = attrvalue(datatype, structuretype) - ncdata = NcData(attributes=[NcAttribute("x", value)]) + ncdata = NcData(attributes={"x": value}) errors = save_errors(ncdata) assert errors == [] diff --git a/tests/unit/xarray/test_to_xarray.py b/tests/unit/xarray/test_to_xarray.py index 11e3259..b1ea919 100644 --- a/tests/unit/xarray/test_to_xarray.py +++ b/tests/unit/xarray/test_to_xarray.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from ncdata import NcAttribute, NcData, NcDimension, NcVariable +from ncdata import NcData, NcDimension, NcVariable from ncdata.xarray import to_xarray from tests import MonitoredArray @@ -86,14 +86,10 @@ def test_kwargs__scaleandoffset(scaleandoffset): NcVariable( name="var_x", dimensions=["x"], - attributes=[ - NcAttribute( - "scale_factor", np.array(0.1, dtype=np.float32) - ), - NcAttribute( - "add_offset", np.array(-5.3, dtype=np.float32) - ), - ], + attributes={ + "scale_factor": np.array(0.1, dtype=np.float32), + "add_offset": np.array(-5.3, dtype=np.float32), + }, data=raw_int_data, ) ],