@@ -109,7 +109,10 @@ class SamplefileDetails:
109109def 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