Skip to content

Commit a93d579

Browse files
committed
WIP: changing saver string handling.
1 parent 4e956e0 commit a93d579

2 files changed

Lines changed: 95 additions & 17 deletions

File tree

lib/iris/fileformats/netcdf/saver.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,7 +1833,9 @@ 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_):
1836+
if not is_dataless and np.issubdtype(
1837+
data.dtype, np.str_
1838+
): # data.dtype.kind in "SU":
18371839
# Deal with string-type variables.
18381840
# Typically CF label variables, but also possibly ancil-vars ?
18391841

@@ -1843,7 +1845,8 @@ def _create_generic_cf_array_var(
18431845
# being a _bytecoding_datasets.EncodedVariable.
18441846
string_dimension_depth = data.dtype.itemsize
18451847

1846-
if data.dtype.kind == "U":
1848+
assert data.dtype.kind == "U"
1849+
if 1:
18471850
# String content (U) instead of bytes (S).
18481851
# For numpy strings, itemsize is **always** a multiple of 4
18491852
if string_dimension_depth % 4 != 0:
@@ -1864,16 +1867,6 @@ def _create_generic_cf_array_var(
18641867
)
18651868
width_fns = bytecoding_datasets._ENCODING_WIDTH_TRANSLATIONS[encoding]
18661869
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)
18771870

18781871
string_dimension_name = "string%d" % string_dimension_depth
18791872

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

Lines changed: 90 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,82 @@ 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 TestReadSpecialCases:
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+
filepath = tmp_path / "utf8_no_encoding.nc"
617+
testdata = make_testfile(
618+
testfile_path=filepath,
619+
encoding_str=data_encoding,
620+
encoding_attr=None,
621+
)
622+
cube = iris.load_cube(filepath)
623+
assert "_Encoding" not in cube.attributes
624+
625+
if data_encoding == "utf8":
626+
assert np.all(cube.data == testdata.datavar_data)
627+
else:
628+
msg = "Character data .* could not be decoded with the 'utf-8' encoding"
629+
with pytest.raises(ValueError, match=msg):
630+
cube.data
631+
632+
def test_read_wrong_encoding__fail(self, tmp_path):
633+
filepath = tmp_path / "missing_encoding.nc"
634+
testdata = make_testfile(
635+
testfile_path=filepath,
636+
encoding_str="utf-16",
637+
encoding_attr="utf-8",
638+
)
639+
640+
from pp_utils import ncdump
641+
642+
ncdump(filepath, opts="-s")
643+
644+
cube = iris.load_cube(filepath)
645+
# NOTE: error only occurs when you attempt to fetch + translate the content.
646+
msg = "Character data .* could not be decoded with the 'utf-8' encoding."
647+
with pytest.raises(ValueError, match=msg):
648+
data = cube.data
649+
650+
651+
class TestWriteSpecialCases:
652+
def test_write_unicode_no_encoding__fail(self, tmp_path):
653+
cube = Cube(np.array("éclair"))
654+
filepath = tmp_path / "write_unicode_no_encoding.nc"
655+
msg = (
656+
"String data written to netcdf character variable 'unknown' "
657+
"could not be represented in encoding 'ascii'"
658+
)
659+
with pytest.raises(ValueError, match=msg):
660+
iris.save(cube, filepath)
661+
662+
def test_write_encoded_overlength__fail(self, tmp_path):
663+
cube = Cube(np.array("éclair"), attributes={"_Encoding": "utf8"})
664+
filepath = tmp_path / "write_encoded_overlength.nc"
665+
msg = (
666+
"String 'éclair' written into netcdf variable 'unknown' "
667+
"with encoding 'utf-8' is 7 bytes long, which exceeds the "
668+
"string dimension length, 6. "
669+
r"This can be fixed by converting the data to a \"wider\" string dtype, "
670+
r"e.g. cube.data = cube.data.astype\(\"U7\"\)"
671+
)
672+
with pytest.raises(iris.exceptions.TranslationError, match=msg):
673+
iris.save(cube, filepath)
674+
675+
@pytest.skip("Under development")
676+
def test_write_multibytes__fail(self, tmp_path):
677+
encoded_bytes = "éclair".encode("utf8")
678+
byte_array = np.array(encoded_bytes)
679+
cube = Cube(byte_array, attributes={"_Encoding": "utf8"})
680+
filepath = tmp_path / "write_multibyte_Sxx.nc"
681+
msg = (
682+
r"Variable 'unknown' has unexpected string/character type, dtype\('S7'\) "
683+
r"-- the data array must contain either single-bytes \(dtype 'S1'\), "
684+
r"or unicode strings \(dtype 'U<n>'\)"
685+
)
686+
with pytest.raises(ValueError, match=msg):
687+
iris.save(cube, filepath)

0 commit comments

Comments
 (0)