Skip to content

Commit 2c276a8

Browse files
committed
Tests for some specific save/load usecases.
1 parent 4e956e0 commit 2c276a8

2 files changed

Lines changed: 141 additions & 38 deletions

File tree

lib/iris/fileformats/netcdf/saver.py

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,8 +1833,8 @@ def _create_generic_cf_array_var(
18331833
if cube is not None and data is not None and cube.shape != data.shape:
18341834
compression_kwargs = {}
18351835

1836-
if not is_dataless and np.issubdtype(data.dtype, np.str_):
1837-
# Deal with string-type variables.
1836+
if not is_dataless and data.dtype.kind == "U":
1837+
# Deal with unicode-string-type variables.
18381838
# Typically CF label variables, but also possibly ancil-vars ?
18391839

18401840
# NOTE: all we are doing here is to calculate the byte dimension length,
@@ -1843,37 +1843,26 @@ def _create_generic_cf_array_var(
18431843
# being a _bytecoding_datasets.EncodedVariable.
18441844
string_dimension_depth = data.dtype.itemsize
18451845

1846-
if data.dtype.kind == "U":
1847-
# String content (U) instead of bytes (S).
1848-
# For numpy strings, itemsize is **always** a multiple of 4
1849-
if string_dimension_depth % 4 != 0:
1850-
msg = (
1851-
"Unexpected numpy string 'itemsize' for element "
1852-
f"{cube_or_mesh.name()}: "
1853-
f"'dtype.itemsize = {string_dimension_depth}, expected "
1854-
"a multiple of four (always)."
1855-
)
1856-
raise ValueError(msg)
1857-
nchars = string_dimension_depth // 4
1858-
1859-
encoding_attr = element.attributes.get("_Encoding", "ascii")
1860-
# Look this up + return a supported encoding name
1861-
# NB implements defaults and raises a warning if given not recognised.
1862-
encoding = bytecoding_datasets._identify_encoding(
1863-
encoding=encoding_attr, var_name=cf_name, writing=True
1846+
# String content (U) instead of bytes (S).
1847+
# For numpy strings, itemsize is **always** a multiple of 4
1848+
if string_dimension_depth % 4 != 0:
1849+
msg = (
1850+
"Unexpected numpy string 'itemsize' for element "
1851+
f"{cube_or_mesh.name()}: "
1852+
f"'dtype.itemsize = {string_dimension_depth}, expected "
1853+
"a multiple of four (always)."
18641854
)
1865-
width_fns = bytecoding_datasets._ENCODING_WIDTH_TRANSLATIONS[encoding]
1866-
string_dimension_depth = width_fns.nchars_2_nbytes(nchars)
1867-
else:
1868-
if data.dtype.kind != "S" or data.dtype.itemsize != 1:
1869-
# Some type of data we don't "understand".
1870-
# NB this includes "Sxx" types other than "S1" : It seems that
1871-
# netCDF4 saves Sxx as variable-length strings. But we don't support that type in Iris.
1872-
msg = (
1873-
f"Variable {cf_name!r} has unexpected string/character dtype, "
1874-
f"{data.dtype} -- should be either 'S' or 'U' type."
1875-
)
1876-
raise ValueError(msg)
1855+
raise ValueError(msg)
1856+
nchars = string_dimension_depth // 4
1857+
1858+
encoding_attr = element.attributes.get("_Encoding", "ascii")
1859+
# Look this up + return a supported encoding name
1860+
# NB implements defaults and raises a warning if given not recognised.
1861+
encoding = bytecoding_datasets._identify_encoding(
1862+
encoding=encoding_attr, var_name=cf_name, writing=True
1863+
)
1864+
width_fns = bytecoding_datasets._ENCODING_WIDTH_TRANSLATIONS[encoding]
1865+
string_dimension_depth = width_fns.nchars_2_nbytes(nchars)
18771866

18781867
string_dimension_name = "string%d" % string_dimension_depth
18791868

@@ -1893,12 +1882,34 @@ def _create_generic_cf_array_var(
18931882
# Create the label coordinate variable.
18941883
cf_var = self._dataset.createVariable(cf_name, "|S1", element_dims)
18951884
else:
1896-
# A normal (numeric) variable.
1885+
# A non-string variable.
18971886
# ensure a valid datatype for the file format.
18981887
if is_dataless:
18991888
dtype = self._DATALESS_DTYPE
19001889
fill_value = self._DATALESS_FILLVALUE
19011890
else:
1891+
# Normal non-string data.
1892+
# NOTE: this includes byte-arrays (S1 only) : however these must
1893+
# use an actual cube dimension for the 'string dimension', which
1894+
# seriously limits the utility of DECODE_TO_STRINGS_ON_READ.
1895+
# TODO: also support netCDF variable-length strings ("string" type).
1896+
# Currently hit a **write error here**, being numpy object dtype ("O").
1897+
if (
1898+
data.dtype.kind not in "iufSU"
1899+
or data.dtype.kind == "S"
1900+
and data.dtype.itemsize != 1
1901+
):
1902+
# This is a type of data we don't "understand".
1903+
# NB this includes "Sxx" types other than "S1" : It seems that
1904+
# netCDF4 saves Sxx as variable-length strings.
1905+
# But we don't support that type in Iris.
1906+
msg = (
1907+
f"Variable {cf_name!r} has unexpected dtype, {data.dtype!r}."
1908+
f"Data content arrays must be numeric, or contain "
1909+
"single-bytes (dtype 'S1'), or unicode strings (dtype 'U<n>')."
1910+
)
1911+
raise ValueError(msg)
1912+
19021913
element_type = type(element).__name__
19031914
data = self._ensure_valid_dtype(data, element_type, element)
19041915
if not packing_controls:

lib/iris/tests/integration/netcdf/test_stringdata.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ class SamplefileDetails:
109109
def make_testfile(
110110
testfile_path: Path,
111111
encoding_str: str,
112-
coords_on_separate_dim: bool,
112+
coords_on_separate_dim: bool = False,
113+
# If set, determines the "_Encoding" attrs content, including None --> no attr.
114+
# Otherwise, they follow 'encoding_str', including NO_ENCODING_STR --> no attr.
115+
encoding_attr: str | None = "<as_encoding_str>",
113116
) -> SamplefileDetails:
114117
"""Create a test netcdf file.
115118
@@ -120,6 +123,9 @@ def make_testfile(
120123
else:
121124
encoding = encoding_str
122125

126+
if encoding_attr == "<as_encoding_str>":
127+
encoding_attr = encoding
128+
123129
data_is_ascii = encoding in (None, "ascii")
124130

125131
numeric_values = np.arange(3.0)
@@ -156,8 +162,8 @@ def make_testfile(
156162
)
157163
v_co[:] = coordvar_bytearray
158164

159-
if encoding is not None:
160-
v_co._Encoding = encoding
165+
if encoding_attr is not None:
166+
v_co._Encoding = encoding_attr
161167

162168
v_numeric = ds.createVariable(
163169
"v_numeric",
@@ -176,8 +182,8 @@ def make_testfile(
176182
)
177183
v_datavar[:] = datavar_bytearray
178184

179-
if encoding is not None:
180-
v_datavar._Encoding = encoding
185+
if encoding_attr is not None:
186+
v_datavar._Encoding = encoding_attr
181187

182188
v_datavar.coordinates = "v_co v_numeric"
183189
finally:
@@ -600,3 +606,89 @@ def test_save_single_unicode__okay(self, tmp_path):
600606
result = iris.load_cube(filepath)
601607
result.attributes.pop("Conventions", None)
602608
assert result == scalar_char_cube
609+
610+
611+
class TestReadParticularCases:
612+
@pytest.mark.parametrize("data_encoding", ["utf8", "utf16", "utf32"])
613+
def test_read_no_encoding(self, tmp_path, data_encoding):
614+
# Check that we can read UTF-8 encoded data, even with no _Encoding attribute.
615+
# This is a common case in the wild, and now accepted by CF as a default.
616+
# However, other encodings will FAIL to decode.
617+
filepath = tmp_path / "utf8_no_encoding.nc"
618+
testdata = make_testfile(
619+
testfile_path=filepath,
620+
encoding_str=data_encoding,
621+
encoding_attr=None,
622+
)
623+
cube = iris.load_cube(filepath)
624+
assert "_Encoding" not in cube.attributes
625+
626+
if data_encoding == "utf8":
627+
assert np.all(cube.data == testdata.datavar_data)
628+
else:
629+
msg = "Character data .* could not be decoded with the 'utf-8' encoding"
630+
with pytest.raises(ValueError, match=msg):
631+
cube.data
632+
633+
def test_read_wrong_encoding__fail(self, tmp_path):
634+
filepath = tmp_path / "missing_encoding.nc"
635+
testdata = make_testfile(
636+
testfile_path=filepath,
637+
encoding_str="utf-16",
638+
encoding_attr="utf-8",
639+
)
640+
cube = iris.load_cube(filepath)
641+
# NOTE: error only occurs when you attempt to fetch + translate the content.
642+
msg = "Character data .* could not be decoded with the 'utf-8' encoding."
643+
with pytest.raises(ValueError, match=msg):
644+
data = cube.data
645+
646+
647+
class TestWriteParticularCases:
648+
def test_write_unicode_no_encoding__fail(self, tmp_path):
649+
cube = Cube(np.array("éclair"))
650+
filepath = tmp_path / "write_unicode_no_encoding.nc"
651+
msg = (
652+
"String data written to netcdf character variable 'unknown' "
653+
"could not be represented in encoding 'ascii'"
654+
)
655+
with pytest.raises(ValueError, match=msg):
656+
iris.save(cube, filepath)
657+
658+
def test_write_encoded_overlength__fail(self, tmp_path):
659+
cube = Cube(np.array("éclair"), attributes={"_Encoding": "utf8"})
660+
filepath = tmp_path / "write_encoded_overlength.nc"
661+
msg = (
662+
"String 'éclair' written into netcdf variable 'unknown' "
663+
"with encoding 'utf-8' is 7 bytes long, which exceeds the "
664+
"string dimension length, 6. "
665+
r"This can be fixed by converting the data to a \"wider\" string dtype, "
666+
r"e.g. cube.data = cube.data.astype\(\"U7\"\)"
667+
)
668+
with pytest.raises(iris.exceptions.TranslationError, match=msg):
669+
iris.save(cube, filepath)
670+
671+
def test_write_multibytes__fail(self, tmp_path):
672+
encoded_bytes = "éclair".encode("utf8")
673+
byte_array = np.array(encoded_bytes)
674+
cube = Cube(byte_array, attributes={"_Encoding": "utf8"})
675+
filepath = tmp_path / "write_multibyte_Sxx.nc"
676+
msg = (
677+
r"Variable 'unknown' has unexpected dtype, dtype\('S7'\)."
678+
"Data content arrays must be numeric, or contain single-bytes "
679+
r"\(dtype 'S1'\), or unicode strings \(dtype 'U<n>'\)."
680+
)
681+
with pytest.raises(ValueError, match=msg):
682+
iris.save(cube, filepath)
683+
684+
def test_write_stringobjects__fail(self, tmp_path):
685+
string_array = np.array(["one", "four"], dtype="O")
686+
cube = Cube(string_array)
687+
filepath = tmp_path / "write_stringobjects.nc"
688+
msg = (
689+
r"Variable 'unknown' has unexpected dtype, dtype\('O'\)."
690+
"Data content arrays must be numeric, or contain single-bytes "
691+
r"\(dtype 'S1'\), or unicode strings \(dtype 'U<n>'\)."
692+
)
693+
with pytest.raises(ValueError, match=msg):
694+
iris.save(cube, filepath)

0 commit comments

Comments
 (0)